diff --git a/.agents/skills/coding-guidance/SKILL.md b/.agents/skills/coding-guidance/SKILL.md
index b259745..9bbc9b1 100644
--- a/.agents/skills/coding-guidance/SKILL.md
+++ b/.agents/skills/coding-guidance/SKILL.md
@@ -23,6 +23,29 @@ description: 本地模式实现指南
客户端是:/Users/leokun/Library/Application\ Support/Cursor
客户端 bundle 是:/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js
+## 可选抓包调试工具
+
+仓库提供了独立的 Cursor 协议抓包调试器。开发者在手动排查协议问题时,可以运行:
+
+```bash
+go run ./cmd/cursor-proxy-debugger
+```
+
+默认代理地址是 `http://127.0.0.1:9090`,调试界面是 `http://127.0.0.1:9091`。该工具可以辅助查看:
+
+- `agent.v1.AgentService/RunSSE`
+- `aiserver.v1.BidiService/BidiAppend`
+- Connect 帧、gzip 压缩内容、Protobuf 解码结果和原始二进制数据
+- 同一 `request_id` 对应的上下行消息
+
+开发者启动工具后,需要自行完成以下配置:
+
+1. 在 Cursor 的代理设置中,将代理修改为工具启动时显示的代理地址,默认是 `http://127.0.0.1:9090`。
+2. 在 Cursor 的 Network 设置中开启 HTTP/1.1。
+3. 从 `http://127.0.0.1:9091/api/ca.crt` 下载代理 CA 证书,并确保 Cursor 信任该证书。
+
+这只是供开发者手动使用的辅助工具,不属于自动化 Debug 流程。不要因为加载此指南就自动启动代理、修改 Cursor 或系统设置、安装证书,或操作 Cursor 发起请求。只有开发者明确表示已经启用抓包时,才把调试界面中的数据作为当前运行证据。调试结束后,提醒开发者恢复原来的 Cursor 代理和 Network 设置。
+
## Cursor 客户端格式化快照
- 如果用户要求提取、格式化、刷新或规范化 Cursor.app 快照流程,使用 `cursor-app-formatted` skill。
diff --git a/Taskfile.yml b/Taskfile.yml
index f00b123..8c75f45 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -178,6 +178,16 @@ tasks:
cmds:
- wails3 dev -config ./build/config.yml -port {{.VITE_PORT}}
+ proxy-debugger:
+ summary: 启动独立 Cursor 协议调试代理
+ cmds:
+ - go run ./cmd/cursor-proxy-debugger
+
+ proxy-debugger:build:
+ summary: 构建独立 Cursor 协议调试代理
+ cmds:
+ - go build -o ./bin/cursor-proxy-debugger ./cmd/cursor-proxy-debugger
+
ads:install:
summary: 安装广告页依赖
dir: '{{.TASKFILE_DIR}}/ads-page'
diff --git a/cmd/cursor-proxy-debugger/main.go b/cmd/cursor-proxy-debugger/main.go
new file mode 100644
index 0000000..f0f1375
--- /dev/null
+++ b/cmd/cursor-proxy-debugger/main.go
@@ -0,0 +1,52 @@
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "log"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ proxydebugger "cursor/cursor-proxy-debugger"
+
+ "github.com/pkg/browser"
+)
+
+func main() {
+ config := proxydebugger.Config{}
+ openBrowser := true
+ flag.StringVar(&config.ProxyAddr, "proxy-addr", "127.0.0.1:9090", "HTTP/HTTPS 代理监听地址")
+ flag.StringVar(&config.UIAddr, "ui-addr", "127.0.0.1:9091", "调试界面监听地址")
+ flag.StringVar(&config.TargetHost, "target-host", "api2.cursor.sh", "需要解密和抓取的目标主机")
+ flag.IntVar(&config.MaxExchanges, "max-exchanges", 200, "内存中保留的最大请求数")
+ flag.BoolVar(&openBrowser, "open", true, "启动后打开浏览器")
+ flag.Parse()
+
+ server, err := proxydebugger.New(config)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if err := server.Start(); err != nil {
+ log.Fatal(err)
+ }
+
+ fmt.Printf("Cursor 协议调试代理已启动\n")
+ fmt.Printf("代理地址: http://%s\n", server.ProxyAddr())
+ fmt.Printf("调试界面: %s\n", server.UIURL())
+ if openBrowser {
+ _ = browser.OpenURL(server.UIURL())
+ }
+
+ signals := make(chan os.Signal, 1)
+ signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
+ <-signals
+
+ shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ if err := server.Close(shutdownContext); err != nil {
+ log.Printf("关闭调试代理失败:%v", err)
+ }
+}
diff --git a/cursor-proxy-debugger/README.en.md b/cursor-proxy-debugger/README.en.md
new file mode 100644
index 0000000..377b450
--- /dev/null
+++ b/cursor-proxy-debugger/README.en.md
@@ -0,0 +1,58 @@
+# Cursor Protocol Debugger
+
+[中文](README.md) | [English](README.en.md)
+
+This standalone local HTTPS debugging proxy captures Cursor's `BidiAppend` and `RunSSE` traffic. It does not modify Cursor, the system proxy, or the installed client.
+
+## Start
+
+Run the following command from the repository root:
+
+```bash
+go run ./cmd/cursor-proxy-debugger
+```
+
+Default addresses:
+
+- HTTP/HTTPS proxy: `127.0.0.1:9090`
+- Debugging UI: `http://127.0.0.1:9091`
+- MITM target: `api2.cursor.sh`
+
+The debugging UI opens automatically after startup.
+
+## Configure Cursor
+
+The tool does not modify Cursor automatically. After starting it, configure Cursor manually:
+
+1. Open Cursor's proxy settings and set the proxy to the address printed by the tool. The default is `http://127.0.0.1:9090`.
+2. Open Cursor's Network settings and enable HTTP/1.1.
+3. Download the proxy CA certificate from `http://127.0.0.1:9091/api/ca.crt` and make sure Cursor trusts it.
+
+Restore the original Cursor proxy and Network settings after debugging to avoid affecting normal network requests.
+
+## Build
+
+```bash
+go build -o bin/cursor-proxy-debugger ./cmd/cursor-proxy-debugger
+```
+
+## Options
+
+```text
+-proxy-addr Proxy listen address; default: 127.0.0.1:9090
+-ui-addr Debugging UI listen address; default: 127.0.0.1:9091
+-target-host Host to decrypt and capture; default: api2.cursor.sh
+-max-exchanges Maximum number of exchanges retained in memory; default: 200
+-open Open the browser after startup; default: true
+```
+
+## Data Handling
+
+- HTTPS MITM is applied only to `target-host`; other CONNECT traffic passes through unchanged.
+- `RunSSE` is decoded incrementally using the 5-byte Connect frame header and supports per-frame gzip decompression.
+- `BidiAppendRequest.data` is further decoded as `agent.v1.AgentClientMessage`.
+- Requests can be sorted chronologically or in reverse chronological order and filtered by protocol `request_id`.
+- The UI supports Simplified Chinese and English, follows the browser language, and remembers a manual selection.
+- Captured traffic is stored only in process memory and is discarded when the process exits.
+- Sensitive HTTP headers such as `Authorization`, `Cookie`, and `Set-Cookie` are hidden in the UI by default.
+- Raw bodies are retained up to 2 MiB per side by default; forwarded traffic is never truncated.
diff --git a/cursor-proxy-debugger/README.md b/cursor-proxy-debugger/README.md
new file mode 100644
index 0000000..3c39f29
--- /dev/null
+++ b/cursor-proxy-debugger/README.md
@@ -0,0 +1,58 @@
+# Cursor 协议调试器
+
+[中文](README.md) | [English](README.en.md)
+
+这是一个独立运行的本地 HTTPS 调试代理,用于观察 Cursor 的 `BidiAppend` 和 `RunSSE` 通信。它不会修改 Cursor、系统代理或已安装客户端。
+
+## 启动
+
+在仓库根目录运行:
+
+```bash
+go run ./cmd/cursor-proxy-debugger
+```
+
+默认监听:
+
+- HTTP/HTTPS 代理:`127.0.0.1:9090`
+- 调试界面:`http://127.0.0.1:9091`
+- MITM 目标:`api2.cursor.sh`
+
+启动后会自动打开调试界面。
+
+## 配置 Cursor
+
+工具不会自动修改 Cursor。启动后需要手动完成以下配置:
+
+1. 打开 Cursor 的代理设置,将代理地址修改为工具启动时显示的地址,默认是 `http://127.0.0.1:9090`。
+2. 打开 Cursor 的 Network 设置,启用 HTTP/1.1。
+3. 从 `http://127.0.0.1:9091/api/ca.crt` 下载代理 CA 证书,并确保 Cursor 信任该证书。
+
+调试结束后,请恢复原来的 Cursor 代理和 Network 设置,以免影响正常网络请求。
+
+## 构建
+
+```bash
+go build -o bin/cursor-proxy-debugger ./cmd/cursor-proxy-debugger
+```
+
+## 参数
+
+```text
+-proxy-addr 代理监听地址,默认 127.0.0.1:9090
+-ui-addr 调试界面监听地址,默认 127.0.0.1:9091
+-target-host 需要解密的目标主机,默认 api2.cursor.sh
+-max-exchanges 内存中保留的最大请求数,默认 200
+-open 启动后是否打开浏览器,默认 true
+```
+
+## 数据处理
+
+- 仅对 `target-host` 执行 HTTPS MITM,其他 CONNECT 流量直接透传。
+- `RunSSE` 按 5 字节 Connect 帧头增量拆帧,支持逐帧 gzip 解压。
+- `BidiAppendRequest.data` 会继续解码为 `agent.v1.AgentClientMessage`。
+- 请求列表支持按抓包时间正序/倒序排列,并可按协议中的 `request_id` 过滤。
+- 调试界面支持简体中文和英文,可跟随浏览器语言并记住手动选择。
+- 抓包只保留在当前进程内存中;关闭进程后消失。
+- `Authorization`、`Cookie`、`Set-Cookie` 等 HTTP 头在界面中默认隐藏。
+- 单侧原始正文默认最多保留 2 MiB;代理转发的数据不会被截断。
diff --git a/cursor-proxy-debugger/capture.go b/cursor-proxy-debugger/capture.go
new file mode 100644
index 0000000..9fefee2
--- /dev/null
+++ b/cursor-proxy-debugger/capture.go
@@ -0,0 +1,88 @@
+package proxydebugger
+
+import (
+ "bytes"
+ "encoding/hex"
+ "io"
+ "sync"
+)
+
+type captureReadCloser struct {
+ source io.ReadCloser
+ mu sync.Mutex
+ buffer bytes.Buffer
+ limit int
+ size int64
+ truncated bool
+ done bool
+ onChunk func([]byte)
+ onDone func(captured []byte, size int64, truncated bool, readErr error)
+}
+
+func newCaptureReadCloser(
+ source io.ReadCloser,
+ limit int,
+ onChunk func([]byte),
+ onDone func(captured []byte, size int64, truncated bool, readErr error),
+) *captureReadCloser {
+ return &captureReadCloser{
+ source: source,
+ limit: limit,
+ onChunk: onChunk,
+ onDone: onDone,
+ }
+}
+
+func (reader *captureReadCloser) Read(payload []byte) (int, error) {
+ read, err := reader.source.Read(payload)
+ if read > 0 {
+ chunk := payload[:read]
+ reader.mu.Lock()
+ reader.size += int64(read)
+ remaining := reader.limit - reader.buffer.Len()
+ if remaining > 0 {
+ captured := read
+ if captured > remaining {
+ captured = remaining
+ }
+ _, _ = reader.buffer.Write(chunk[:captured])
+ }
+ if reader.buffer.Len() >= reader.limit && reader.size > int64(reader.buffer.Len()) {
+ reader.truncated = true
+ }
+ reader.mu.Unlock()
+ if reader.onChunk != nil {
+ reader.onChunk(append([]byte(nil), chunk...))
+ }
+ }
+ if err != nil {
+ reader.finish(err)
+ }
+ return read, err
+}
+
+func (reader *captureReadCloser) Close() error {
+ err := reader.source.Close()
+ reader.finish(err)
+ return err
+}
+
+func (reader *captureReadCloser) finish(readErr error) {
+ reader.mu.Lock()
+ if reader.done {
+ reader.mu.Unlock()
+ return
+ }
+ reader.done = true
+ captured := append([]byte(nil), reader.buffer.Bytes()...)
+ size := reader.size
+ truncated := reader.truncated
+ reader.mu.Unlock()
+ if reader.onDone != nil {
+ reader.onDone(captured, size, truncated, readErr)
+ }
+}
+
+func rawHex(payload []byte) string {
+ return hex.EncodeToString(payload)
+}
diff --git a/cursor-proxy-debugger/decode.go b/cursor-proxy-debugger/decode.go
new file mode 100644
index 0000000..fa8b136
--- /dev/null
+++ b/cursor-proxy-debugger/decode.go
@@ -0,0 +1,236 @@
+package proxydebugger
+
+import (
+ "bytes"
+ "compress/gzip"
+ "encoding/binary"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "strings"
+
+ "cursor/gen/agentv1"
+ "cursor/gen/aiserverv1"
+ agentprotocol "cursor/internal/backend/agent/protocol"
+
+ "google.golang.org/protobuf/encoding/protojson"
+ "google.golang.org/protobuf/proto"
+)
+
+const maxConnectFrameBytes = 64 << 20
+
+type connectFrameDecoder struct {
+ buffer []byte
+ messageType string
+ codec string
+ maxFrames int
+ frameCount int
+ onFrame func(FrameView)
+}
+
+func newConnectFrameDecoder(messageType string, codec string, maxFrames int, onFrame func(FrameView)) *connectFrameDecoder {
+ return &connectFrameDecoder{
+ messageType: messageType,
+ codec: strings.TrimSpace(codec),
+ maxFrames: maxFrames,
+ onFrame: onFrame,
+ }
+}
+
+func (decoder *connectFrameDecoder) Write(payload []byte) {
+ if len(payload) == 0 || decoder.frameCount >= decoder.maxFrames {
+ return
+ }
+ decoder.buffer = append(decoder.buffer, payload...)
+ for len(decoder.buffer) >= 5 && decoder.frameCount < decoder.maxFrames {
+ flags := decoder.buffer[0]
+ length := int(binary.BigEndian.Uint32(decoder.buffer[1:5]))
+ if length < 0 || length > maxConnectFrameBytes {
+ decoder.emit(FrameView{Flags: flags, Length: length, Error: "Connect 帧长度异常"})
+ decoder.buffer = nil
+ return
+ }
+ if len(decoder.buffer) < 5+length {
+ return
+ }
+ framePayload := append([]byte(nil), decoder.buffer[5:5+length]...)
+ decoder.buffer = decoder.buffer[5+length:]
+ decoder.emit(decoder.decode(flags, framePayload))
+ }
+}
+
+func (decoder *connectFrameDecoder) Close() {
+ if len(decoder.buffer) > 0 && decoder.frameCount < decoder.maxFrames {
+ decoder.emit(FrameView{
+ Length: len(decoder.buffer),
+ RawHex: clippedHex(decoder.buffer, 4096),
+ Error: "流结束时仍有不完整的 Connect 帧",
+ })
+ }
+ decoder.buffer = nil
+}
+
+func (decoder *connectFrameDecoder) emit(frame FrameView) {
+ frame.Index = decoder.frameCount
+ decoder.frameCount++
+ if decoder.onFrame != nil {
+ decoder.onFrame(frame)
+ }
+}
+
+func (decoder *connectFrameDecoder) decode(flags uint8, payload []byte) FrameView {
+ frame := FrameView{
+ Flags: flags,
+ Length: len(payload),
+ Compressed: flags&0x01 != 0,
+ EndStream: flags&0x02 != 0,
+ RawHex: clippedHex(payload, 4096),
+ }
+ decoded := payload
+ if frame.Compressed {
+ var err error
+ decoded, err = decompressPayload(payload, decoder.codec)
+ if err != nil {
+ frame.Error = err.Error()
+ return frame
+ }
+ }
+ if frame.EndStream {
+ frame.Kind = "end_stream"
+ frame.MessageType = "connect.error.v1.EndStreamResponse"
+ frame.JSON = prettyJSON(decoded)
+ return frame
+ }
+
+ message := newMessage(decoder.messageType)
+ if message == nil {
+ frame.Error = "未知的 protobuf 消息类型"
+ return frame
+ }
+ if err := proto.Unmarshal(decoded, message); err != nil {
+ frame.Error = fmt.Sprintf("protobuf 解码失败:%v", err)
+ return frame
+ }
+ frame.MessageType = decoder.messageType
+ frame.Kind = activeOneofName(message)
+ if requestID, ok := message.(*aiserverv1.BidiRequestId); ok {
+ frame.RequestID = strings.TrimSpace(requestID.GetRequestId())
+ }
+ frame.JSON = marshalProtoJSON(message)
+ return frame
+}
+
+func decompressPayload(payload []byte, codec string) ([]byte, error) {
+ if codec != "" && !strings.EqualFold(codec, "gzip") {
+ return nil, fmt.Errorf("暂不支持压缩算法 %q", codec)
+ }
+ reader, err := gzip.NewReader(bytes.NewReader(payload))
+ if err != nil {
+ return nil, fmt.Errorf("gzip 解压失败:%w", err)
+ }
+ defer reader.Close()
+ decoded, err := io.ReadAll(io.LimitReader(reader, maxConnectFrameBytes+1))
+ if err != nil {
+ return nil, fmt.Errorf("读取 gzip 内容失败:%w", err)
+ }
+ if len(decoded) > maxConnectFrameBytes {
+ return nil, fmt.Errorf("gzip 解压后超过 %d 字节限制", maxConnectFrameBytes)
+ }
+ return decoded, nil
+}
+
+func decodeUnary(path string, payload []byte) (decodedJSON string, kind string, requestID string, err error) {
+ var message proto.Message
+ switch path {
+ case "/aiserver.v1.BidiService/BidiAppend":
+ request := &aiserverv1.BidiAppendRequest{}
+ if err := proto.Unmarshal(payload, request); err != nil {
+ return "", "", "", err
+ }
+ requestID := strings.TrimSpace(request.GetRequestId().GetRequestId())
+ outer := marshalProtoJSON(request)
+ clientMessage, clientKind, decodeErr := agentprotocol.DecodeAgentClientMessage(request.GetData())
+ if decodeErr != nil || clientMessage == nil {
+ return outer, "bidi_append", requestID, decodeErr
+ }
+ combined := struct {
+ BidiAppendRequest json.RawMessage `json:"bidi_append_request"`
+ AgentClientKind string `json:"agent_client_kind"`
+ AgentClient json.RawMessage `json:"agent_client_message"`
+ }{
+ BidiAppendRequest: json.RawMessage(outer),
+ AgentClientKind: clientKind,
+ AgentClient: json.RawMessage(marshalProtoJSON(clientMessage)),
+ }
+ formatted, marshalErr := json.MarshalIndent(combined, "", " ")
+ return string(formatted), clientKind, requestID, marshalErr
+ default:
+ message = nil
+ }
+ if message == nil {
+ return "", "", "", nil
+ }
+ return marshalProtoJSON(message), activeOneofName(message), "", nil
+}
+
+func newMessage(messageType string) proto.Message {
+ switch messageType {
+ case "aiserver.v1.BidiRequestId":
+ return &aiserverv1.BidiRequestId{}
+ case "agent.v1.AgentServerMessage":
+ return &agentv1.AgentServerMessage{}
+ default:
+ return nil
+ }
+}
+
+func marshalProtoJSON(message proto.Message) string {
+ if message == nil {
+ return ""
+ }
+ payload, err := (protojson.MarshalOptions{
+ UseProtoNames: true,
+ EmitUnpopulated: false,
+ Indent: " ",
+ }).Marshal(message)
+ if err != nil {
+ return ""
+ }
+ return string(payload)
+}
+
+func activeOneofName(message proto.Message) string {
+ if message == nil {
+ return ""
+ }
+ reflected := message.ProtoReflect()
+ oneofs := reflected.Descriptor().Oneofs()
+ for index := 0; index < oneofs.Len(); index++ {
+ oneof := oneofs.Get(index)
+ field := reflected.WhichOneof(oneof)
+ if field != nil {
+ return string(field.Name())
+ }
+ }
+ return string(reflected.Descriptor().Name())
+}
+
+func prettyJSON(payload []byte) string {
+ var target any
+ if err := json.Unmarshal(payload, &target); err != nil {
+ return string(payload)
+ }
+ formatted, err := json.MarshalIndent(target, "", " ")
+ if err != nil {
+ return string(payload)
+ }
+ return string(formatted)
+}
+
+func clippedHex(payload []byte, max int) string {
+ if len(payload) > max {
+ return hex.EncodeToString(payload[:max]) + "..."
+ }
+ return hex.EncodeToString(payload)
+}
diff --git a/cursor-proxy-debugger/proxy.go b/cursor-proxy-debugger/proxy.go
new file mode 100644
index 0000000..11d15d7
--- /dev/null
+++ b/cursor-proxy-debugger/proxy.go
@@ -0,0 +1,421 @@
+package proxydebugger
+
+import (
+ "context"
+ "crypto/tls"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "net"
+ "net/http"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "cursor/internal/certs"
+
+ "github.com/elazarl/goproxy"
+)
+
+type exchangeContext struct {
+ id string
+}
+
+// Server runs the HTTPS debugging proxy and its local web UI.
+type Server struct {
+ config Config
+ certManager *certs.Manager
+ store *exchangeStore
+ counter atomic.Uint64
+ proxyServer *http.Server
+ uiServer *http.Server
+ proxyLn net.Listener
+ uiLn net.Listener
+ runMu sync.Mutex
+}
+
+// New creates a standalone Cursor protocol debugger.
+func New(config Config) (*Server, error) {
+ config = config.normalized()
+ if err := validateLoopbackAddress(config.UIAddr); err != nil {
+ return nil, err
+ }
+ manager, err := certs.NewEmbeddedManager()
+ if err != nil {
+ return nil, fmt.Errorf("加载 MITM CA 失败:%w", err)
+ }
+ server := &Server{
+ config: config,
+ certManager: manager,
+ store: newExchangeStore(config.MaxExchanges),
+ }
+ proxyHandler, err := server.newProxyHandler()
+ if err != nil {
+ return nil, err
+ }
+ server.proxyServer = &http.Server{
+ Handler: proxyHandler,
+ ErrorLog: log.New(io.Discard, "", 0),
+ }
+ server.uiServer = &http.Server{Handler: server.newUIHandler()}
+ return server, nil
+}
+
+// Start starts both listeners without modifying Cursor or system proxy settings.
+func (server *Server) Start() error {
+ server.runMu.Lock()
+ defer server.runMu.Unlock()
+ if server.proxyLn != nil || server.uiLn != nil {
+ return errors.New("调试代理已经启动")
+ }
+ proxyListener, err := net.Listen("tcp", server.config.ProxyAddr)
+ if err != nil {
+ return fmt.Errorf("启动代理监听失败:%w", err)
+ }
+ uiListener, err := net.Listen("tcp", server.config.UIAddr)
+ if err != nil {
+ _ = proxyListener.Close()
+ return fmt.Errorf("启动调试界面失败:%w", err)
+ }
+ server.proxyLn = proxyListener
+ server.uiLn = uiListener
+ go func() { _ = server.proxyServer.Serve(proxyListener) }()
+ go func() { _ = server.uiServer.Serve(uiListener) }()
+ return nil
+}
+
+// Close stops both listeners.
+func (server *Server) Close(ctx context.Context) error {
+ server.runMu.Lock()
+ proxyServer := server.proxyServer
+ uiServer := server.uiServer
+ server.proxyLn = nil
+ server.uiLn = nil
+ server.runMu.Unlock()
+ var errorsList []error
+ if proxyServer != nil {
+ if err := proxyServer.Shutdown(ctx); err != nil {
+ errorsList = append(errorsList, err)
+ }
+ }
+ if uiServer != nil {
+ if err := uiServer.Shutdown(ctx); err != nil {
+ errorsList = append(errorsList, err)
+ }
+ }
+ return errors.Join(errorsList...)
+}
+
+func (server *Server) ProxyAddr() string { return server.config.ProxyAddr }
+func (server *Server) UIAddr() string { return server.config.UIAddr }
+func (server *Server) UIURL() string { return "http://" + browserAddress(server.config.UIAddr) }
+
+func (server *Server) newProxyHandler() (*goproxy.ProxyHttpServer, error) {
+ proxy := goproxy.NewProxyHttpServer()
+ proxy.Verbose = false
+ proxy.AllowHTTP2 = true
+ proxy.Logger = log.New(io.Discard, "", 0)
+ proxy.ConnectionErrHandler = func(_ io.Writer, context *goproxy.ProxyCtx, connectionErr error) {
+ id := exchangeID(context)
+ if id == "" {
+ return
+ }
+ server.store.update(id, func(exchange *Exchange) {
+ exchange.State = "error"
+ exchange.Error = connectionErr.Error()
+ exchange.DurationMS = elapsedMS(exchange.StartedAt)
+ })
+ }
+ proxy.Tr = &http.Transport{
+ Proxy: nil,
+ DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
+ ForceAttemptHTTP2: true,
+ MaxIdleConns: 200,
+ IdleConnTimeout: 90 * time.Second,
+ TLSHandshakeTimeout: 10 * time.Second,
+ ExpectContinueTimeout: 1 * time.Second,
+ }
+
+ caCertificate, err := server.certManager.CATLSCertificate()
+ if err != nil {
+ return nil, fmt.Errorf("读取 MITM CA 失败:%w", err)
+ }
+ baseTLSConfig := goproxy.TLSConfigFromCA(caCertificate)
+ mitmAction := &goproxy.ConnectAction{
+ Action: goproxy.ConnectMitm,
+ TLSConfig: func(host string, context *goproxy.ProxyCtx) (*tls.Config, error) {
+ return baseTLSConfig(host, context)
+ },
+ }
+ proxy.OnRequest().HandleConnectFunc(func(host string, _ *goproxy.ProxyCtx) (*goproxy.ConnectAction, string) {
+ if server.matchesTargetHost(host) {
+ return mitmAction, host
+ }
+ return goproxy.OkConnect, host
+ })
+
+ proxy.OnRequest().DoFunc(server.captureRequest)
+ proxy.OnResponse().DoFunc(server.captureResponse)
+ return proxy, nil
+}
+
+func (server *Server) captureRequest(request *http.Request, context *goproxy.ProxyCtx) (*http.Request, *http.Response) {
+ if request == nil || request.Method == http.MethodConnect || !server.matchesHTTPRequest(request) {
+ return request, nil
+ }
+ id := strconv.FormatUint(server.counter.Add(1), 10)
+ path := request.URL.Path
+ requestCodec := requestContentCodec(path, request.Header)
+ exchange := &Exchange{
+ ExchangeSummary: ExchangeSummary{
+ ID: id,
+ StartedAt: time.Now(),
+ Method: request.Method,
+ URL: request.URL.String(),
+ Host: request.URL.Host,
+ Path: path,
+ State: "pending",
+ },
+ Request: Payload{
+ Headers: sortedHeaders(request.Header),
+ ContentType: request.Header.Get("Content-Type"),
+ ContentCodec: requestCodec,
+ Frames: make([]FrameView, 0),
+ },
+ Response: Payload{Headers: make([]Header, 0), Frames: make([]FrameView, 0)},
+ }
+ server.store.create(exchange)
+ context.UserData = exchangeContext{id: id}
+
+ if request.Body == nil {
+ server.finishRequestBody(id, path, requestCodec, nil, 0, false, nil)
+ return request, nil
+ }
+ var frameDecoder *connectFrameDecoder
+ if path == "/agent.v1.AgentService/RunSSE" {
+ frameDecoder = newConnectFrameDecoder(
+ "aiserver.v1.BidiRequestId",
+ requestCodec,
+ server.config.MaxFrames,
+ func(frame FrameView) { server.appendRequestFrame(id, frame) },
+ )
+ }
+ request.Body = newCaptureReadCloser(
+ request.Body,
+ server.config.MaxCaptureBytes,
+ func(chunk []byte) {
+ if frameDecoder != nil {
+ frameDecoder.Write(chunk)
+ }
+ },
+ func(captured []byte, size int64, truncated bool, readErr error) {
+ if frameDecoder != nil {
+ frameDecoder.Close()
+ }
+ server.finishRequestBody(id, path, requestCodec, captured, size, truncated, readErr)
+ },
+ )
+ return request, nil
+}
+
+func (server *Server) captureResponse(response *http.Response, context *goproxy.ProxyCtx) *http.Response {
+ id := exchangeID(context)
+ if id == "" || response == nil {
+ return response
+ }
+ server.store.update(id, func(exchange *Exchange) {
+ exchange.Status = response.StatusCode
+ exchange.State = "streaming"
+ exchange.DurationMS = elapsedMS(exchange.StartedAt)
+ exchange.Response.Headers = sortedHeaders(response.Header)
+ exchange.Response.ContentType = response.Header.Get("Content-Type")
+ exchange.Response.ContentCodec = responseContentCodec(response.Header)
+ })
+ if response.Body == nil {
+ server.finishResponseBody(id, nil, 0, false, nil)
+ return response
+ }
+
+ path := ""
+ if response.Request != nil && response.Request.URL != nil {
+ path = response.Request.URL.Path
+ }
+ var frameDecoder *connectFrameDecoder
+ if path == "/agent.v1.AgentService/RunSSE" {
+ frameDecoder = newConnectFrameDecoder(
+ "agent.v1.AgentServerMessage",
+ response.Header.Get("Connect-Content-Encoding"),
+ server.config.MaxFrames,
+ func(frame FrameView) { server.appendResponseFrame(id, frame) },
+ )
+ }
+ response.Body = newCaptureReadCloser(
+ response.Body,
+ server.config.MaxCaptureBytes,
+ func(chunk []byte) {
+ if frameDecoder != nil {
+ frameDecoder.Write(chunk)
+ }
+ },
+ func(captured []byte, size int64, truncated bool, readErr error) {
+ if frameDecoder != nil {
+ frameDecoder.Close()
+ }
+ server.finishResponseBody(id, captured, size, truncated, readErr)
+ },
+ )
+ return response
+}
+
+func (server *Server) finishRequestBody(id, path string, codec string, captured []byte, size int64, truncated bool, readErr error) {
+ decodePayload := captured
+ var contentDecodeErr error
+ if path == "/aiserver.v1.BidiService/BidiAppend" && truncated {
+ contentDecodeErr = errors.New("请求正文超过抓取上限,无法完整解码")
+ } else if path == "/aiserver.v1.BidiService/BidiAppend" && codec != "" && !strings.EqualFold(codec, "identity") {
+ decodePayload, contentDecodeErr = decompressPayload(captured, codec)
+ }
+ decodedJSON, kind, requestID, decodeErr := "", "", "", contentDecodeErr
+ if decodeErr == nil {
+ decodedJSON, kind, requestID, decodeErr = decodeUnary(path, decodePayload)
+ }
+ server.store.update(id, func(exchange *Exchange) {
+ exchange.RequestBytes = size
+ exchange.Request.Size = size
+ exchange.Request.RawHex = rawHex(captured)
+ exchange.Request.RawTruncated = truncated
+ if decodedJSON != "" {
+ exchange.Request.DecodedJSON = decodedJSON
+ }
+ if kind != "" {
+ exchange.RequestKind = kind
+ }
+ if requestID != "" {
+ exchange.RequestID = requestID
+ }
+ if decodeErr != nil {
+ exchange.Request.DecodeError = decodeErr.Error()
+ }
+ if readErr != nil && !errors.Is(readErr, io.EOF) {
+ exchange.Error = readErr.Error()
+ }
+ })
+}
+
+func requestContentCodec(path string, headers http.Header) string {
+ if path == "/agent.v1.AgentService/RunSSE" {
+ return strings.TrimSpace(headers.Get("Connect-Content-Encoding"))
+ }
+ return strings.TrimSpace(headers.Get("Content-Encoding"))
+}
+
+func responseContentCodec(headers http.Header) string {
+ if codec := strings.TrimSpace(headers.Get("Connect-Content-Encoding")); codec != "" {
+ return codec
+ }
+ return strings.TrimSpace(headers.Get("Content-Encoding"))
+}
+
+func (server *Server) finishResponseBody(id string, captured []byte, size int64, truncated bool, readErr error) {
+ server.store.update(id, func(exchange *Exchange) {
+ exchange.ResponseBytes = size
+ exchange.Response.Size = size
+ exchange.Response.RawHex = rawHex(captured)
+ exchange.Response.RawTruncated = truncated
+ exchange.DurationMS = elapsedMS(exchange.StartedAt)
+ exchange.State = "completed"
+ if readErr != nil && !errors.Is(readErr, io.EOF) {
+ exchange.State = "error"
+ exchange.Error = readErr.Error()
+ }
+ })
+}
+
+func (server *Server) appendRequestFrame(id string, frame FrameView) {
+ server.store.update(id, func(exchange *Exchange) {
+ if len(exchange.Request.Frames) < server.config.MaxFrames {
+ exchange.Request.Frames = append(exchange.Request.Frames, frame)
+ }
+ if frame.Kind != "" {
+ exchange.RequestKind = frame.Kind
+ }
+ if frame.RequestID != "" {
+ exchange.RequestID = frame.RequestID
+ }
+ })
+}
+
+func (server *Server) appendResponseFrame(id string, frame FrameView) {
+ server.store.update(id, func(exchange *Exchange) {
+ if len(exchange.Response.Frames) < server.config.MaxFrames {
+ exchange.Response.Frames = append(exchange.Response.Frames, frame)
+ }
+ exchange.FrameCount = len(exchange.Response.Frames)
+ if frame.Kind != "" && frame.Kind != "end_stream" {
+ exchange.ResponseKind = frame.Kind
+ }
+ if frame.Error != "" {
+ exchange.Response.DecodeError = frame.Error
+ }
+ })
+}
+
+func (server *Server) matchesHTTPRequest(request *http.Request) bool {
+ if request == nil {
+ return false
+ }
+ host := request.Host
+ if request.URL != nil && request.URL.Host != "" {
+ host = request.URL.Host
+ }
+ return server.matchesTargetHost(host)
+}
+
+func (server *Server) matchesTargetHost(host string) bool {
+ host = strings.TrimSpace(strings.ToLower(host))
+ if parsedHost, _, err := net.SplitHostPort(host); err == nil {
+ host = parsedHost
+ }
+ target := strings.TrimSpace(strings.ToLower(server.config.TargetHost))
+ return host == target
+}
+
+func exchangeID(context *goproxy.ProxyCtx) string {
+ if context == nil {
+ return ""
+ }
+ value, ok := context.UserData.(exchangeContext)
+ if !ok {
+ return ""
+ }
+ return value.id
+}
+
+func browserAddress(address string) string {
+ host, port, err := net.SplitHostPort(address)
+ if err != nil {
+ return address
+ }
+ if host == "" || host == "0.0.0.0" || host == "::" {
+ host = "127.0.0.1"
+ }
+ return net.JoinHostPort(host, port)
+}
+
+func validateLoopbackAddress(address string) error {
+ host, _, err := net.SplitHostPort(address)
+ if err != nil {
+ return fmt.Errorf("调试界面监听地址无效:%w", err)
+ }
+ if strings.EqualFold(host, "localhost") {
+ return nil
+ }
+ ip := net.ParseIP(host)
+ if ip == nil || !ip.IsLoopback() {
+ return errors.New("调试界面只能监听本机回环地址")
+ }
+ return nil
+}
diff --git a/cursor-proxy-debugger/store.go b/cursor-proxy-debugger/store.go
new file mode 100644
index 0000000..cfebfaa
--- /dev/null
+++ b/cursor-proxy-debugger/store.go
@@ -0,0 +1,162 @@
+package proxydebugger
+
+import (
+ "sort"
+ "sync"
+ "time"
+)
+
+type exchangeStore struct {
+ mu sync.RWMutex
+ max int
+ order []string
+ exchanges map[string]*Exchange
+ subscribers map[chan storeEvent]struct{}
+}
+
+func newExchangeStore(max int) *exchangeStore {
+ return &exchangeStore{
+ max: max,
+ exchanges: make(map[string]*Exchange),
+ subscribers: make(map[chan storeEvent]struct{}),
+ }
+}
+
+func (store *exchangeStore) create(exchange *Exchange) {
+ store.mu.Lock()
+ store.exchanges[exchange.ID] = exchange
+ store.order = append([]string{exchange.ID}, store.order...)
+ for len(store.order) > store.max {
+ oldest := store.order[len(store.order)-1]
+ store.order = store.order[:len(store.order)-1]
+ delete(store.exchanges, oldest)
+ }
+ store.mu.Unlock()
+ store.publish(storeEvent{Type: "created", ID: exchange.ID})
+}
+
+func (store *exchangeStore) update(id string, apply func(*Exchange)) {
+ store.mu.Lock()
+ if exchange := store.exchanges[id]; exchange != nil {
+ apply(exchange)
+ }
+ store.mu.Unlock()
+ store.publish(storeEvent{Type: "updated", ID: id})
+}
+
+func (store *exchangeStore) summaries() []ExchangeSummary {
+ store.mu.RLock()
+ defer store.mu.RUnlock()
+ result := make([]ExchangeSummary, 0, len(store.order))
+ for _, id := range store.order {
+ if exchange := store.exchanges[id]; exchange != nil {
+ result = append(result, exchange.ExchangeSummary)
+ }
+ }
+ return result
+}
+
+func (store *exchangeStore) get(id string) (Exchange, bool) {
+ store.mu.RLock()
+ defer store.mu.RUnlock()
+ exchange := store.exchanges[id]
+ if exchange == nil {
+ return Exchange{}, false
+ }
+ return cloneExchange(*exchange), true
+}
+
+func (store *exchangeStore) clear() {
+ store.mu.Lock()
+ store.order = nil
+ store.exchanges = make(map[string]*Exchange)
+ store.mu.Unlock()
+ store.publish(storeEvent{Type: "cleared"})
+}
+
+func (store *exchangeStore) subscribe() (<-chan storeEvent, func()) {
+ updates := make(chan storeEvent, 32)
+ store.mu.Lock()
+ store.subscribers[updates] = struct{}{}
+ store.mu.Unlock()
+ return updates, func() {
+ store.mu.Lock()
+ if _, ok := store.subscribers[updates]; ok {
+ delete(store.subscribers, updates)
+ close(updates)
+ }
+ store.mu.Unlock()
+ }
+}
+
+func (store *exchangeStore) publish(event storeEvent) {
+ store.mu.RLock()
+ defer store.mu.RUnlock()
+ for subscriber := range store.subscribers {
+ select {
+ case subscriber <- event:
+ default:
+ }
+ }
+}
+
+func cloneExchange(exchange Exchange) Exchange {
+ exchange.Request = clonePayload(exchange.Request)
+ exchange.Response = clonePayload(exchange.Response)
+ return exchange
+}
+
+func clonePayload(payload Payload) Payload {
+ payload.Headers = append([]Header(nil), payload.Headers...)
+ payload.Frames = append([]FrameView(nil), payload.Frames...)
+ return payload
+}
+
+func elapsedMS(startedAt time.Time) int64 {
+ if startedAt.IsZero() {
+ return 0
+ }
+ return time.Since(startedAt).Milliseconds()
+}
+
+func sortedHeaders(headers map[string][]string) []Header {
+ result := make([]Header, 0, len(headers))
+ for name, values := range headers {
+ value := ""
+ for index, item := range values {
+ if index > 0 {
+ value += ", "
+ }
+ value += item
+ }
+ if isSensitiveHeader(name) && value != "" {
+ value = "[已隐藏]"
+ }
+ result = append(result, Header{Name: name, Value: value})
+ }
+ sort.Slice(result, func(left, right int) bool {
+ return result[left].Name < result[right].Name
+ })
+ return result
+}
+
+func isSensitiveHeader(name string) bool {
+ switch httpCanonicalLower(name) {
+ case "authorization", "cookie", "set-cookie", "proxy-authorization", "x-api-key":
+ return true
+ default:
+ return false
+ }
+}
+
+func httpCanonicalLower(value string) string {
+ buffer := make([]byte, len(value))
+ for index := range value {
+ character := value[index]
+ if character >= 'A' && character <= 'Z' {
+ character += 'a' - 'A'
+ }
+ buffer[index] = character
+ }
+ return string(buffer)
+}
diff --git a/cursor-proxy-debugger/types.go b/cursor-proxy-debugger/types.go
new file mode 100644
index 0000000..a478fe2
--- /dev/null
+++ b/cursor-proxy-debugger/types.go
@@ -0,0 +1,110 @@
+package proxydebugger
+
+import "time"
+
+const (
+ defaultProxyAddr = "127.0.0.1:9090"
+ defaultUIAddr = "127.0.0.1:9091"
+ defaultTargetHost = "api2.cursor.sh"
+ defaultMaxExchanges = 200
+ defaultMaxCaptureBytes = 2 << 20
+ defaultMaxFrames = 2000
+)
+
+// Config controls the standalone proxy debugger.
+type Config struct {
+ ProxyAddr string
+ UIAddr string
+ TargetHost string
+ MaxExchanges int
+ MaxCaptureBytes int
+ MaxFrames int
+}
+
+func (config Config) normalized() Config {
+ if config.ProxyAddr == "" {
+ config.ProxyAddr = defaultProxyAddr
+ }
+ if config.UIAddr == "" {
+ config.UIAddr = defaultUIAddr
+ }
+ if config.TargetHost == "" {
+ config.TargetHost = defaultTargetHost
+ }
+ if config.MaxExchanges <= 0 {
+ config.MaxExchanges = defaultMaxExchanges
+ }
+ if config.MaxCaptureBytes <= 0 {
+ config.MaxCaptureBytes = defaultMaxCaptureBytes
+ }
+ if config.MaxFrames <= 0 {
+ config.MaxFrames = defaultMaxFrames
+ }
+ return config
+}
+
+// ExchangeSummary is the compact request-list representation.
+type ExchangeSummary struct {
+ ID string `json:"id"`
+ StartedAt time.Time `json:"startedAt"`
+ Method string `json:"method"`
+ URL string `json:"url"`
+ Host string `json:"host"`
+ Path string `json:"path"`
+ Status int `json:"status"`
+ State string `json:"state"`
+ DurationMS int64 `json:"durationMs"`
+ RequestBytes int64 `json:"requestBytes"`
+ ResponseBytes int64 `json:"responseBytes"`
+ RequestID string `json:"requestId,omitempty"`
+ RequestKind string `json:"requestKind,omitempty"`
+ ResponseKind string `json:"responseKind,omitempty"`
+ FrameCount int `json:"frameCount"`
+ Error string `json:"error,omitempty"`
+}
+
+// Exchange contains the request and response detail shown by the debugger.
+type Exchange struct {
+ ExchangeSummary
+ Request Payload `json:"request"`
+ Response Payload `json:"response"`
+}
+
+// Payload contains headers, captured raw bytes, and decoded protobuf frames.
+type Payload struct {
+ Headers []Header `json:"headers"`
+ ContentType string `json:"contentType,omitempty"`
+ ContentCodec string `json:"contentCodec,omitempty"`
+ Size int64 `json:"size"`
+ RawHex string `json:"rawHex,omitempty"`
+ RawTruncated bool `json:"rawTruncated,omitempty"`
+ DecodedJSON string `json:"decodedJson,omitempty"`
+ DecodeError string `json:"decodeError,omitempty"`
+ Frames []FrameView `json:"frames,omitempty"`
+}
+
+// Header is a stable, sorted HTTP header pair.
+type Header struct {
+ Name string `json:"name"`
+ Value string `json:"value"`
+}
+
+// FrameView describes one Connect streaming envelope.
+type FrameView struct {
+ Index int `json:"index"`
+ Flags uint8 `json:"flags"`
+ Length int `json:"length"`
+ Compressed bool `json:"compressed"`
+ EndStream bool `json:"endStream"`
+ Kind string `json:"kind,omitempty"`
+ MessageType string `json:"messageType,omitempty"`
+ RequestID string `json:"requestId,omitempty"`
+ JSON string `json:"json,omitempty"`
+ RawHex string `json:"rawHex,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+type storeEvent struct {
+ Type string `json:"type"`
+ ID string `json:"id,omitempty"`
+}
diff --git a/cursor-proxy-debugger/web.go b/cursor-proxy-debugger/web.go
new file mode 100644
index 0000000..255f6f6
--- /dev/null
+++ b/cursor-proxy-debugger/web.go
@@ -0,0 +1,112 @@
+package proxydebugger
+
+import (
+ "embed"
+ "encoding/json"
+ "fmt"
+ "io/fs"
+ "net/http"
+ "strings"
+ "time"
+
+ "cursor/internal/certs"
+)
+
+//go:embed web/*
+var webAssets embed.FS
+
+func (server *Server) newUIHandler() http.Handler {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /api/status", server.handleStatus)
+ mux.HandleFunc("GET /api/exchanges", server.handleExchangeList)
+ mux.HandleFunc("GET /api/exchanges/{id}", server.handleExchangeDetail)
+ mux.HandleFunc("DELETE /api/exchanges", server.handleClearExchanges)
+ mux.HandleFunc("GET /api/events", server.handleEvents)
+ mux.HandleFunc("GET /api/ca.crt", server.handleCACertificate)
+ assets, _ := fs.Sub(webAssets, "web")
+ fileServer := http.FileServer(http.FS(assets))
+ mux.Handle("/", fileServer)
+ return securityHeaders(mux)
+}
+
+func (server *Server) handleStatus(writer http.ResponseWriter, _ *http.Request) {
+ writeJSON(writer, http.StatusOK, map[string]any{
+ "proxyAddr": server.config.ProxyAddr,
+ "uiAddr": server.config.UIAddr,
+ "targetHost": server.config.TargetHost,
+ "running": true,
+ })
+}
+
+func (server *Server) handleExchangeList(writer http.ResponseWriter, _ *http.Request) {
+ writeJSON(writer, http.StatusOK, server.store.summaries())
+}
+
+func (server *Server) handleExchangeDetail(writer http.ResponseWriter, request *http.Request) {
+ id := strings.TrimSpace(request.PathValue("id"))
+ exchange, ok := server.store.get(id)
+ if !ok {
+ writeJSON(writer, http.StatusNotFound, map[string]string{"error": "请求记录不存在"})
+ return
+ }
+ writeJSON(writer, http.StatusOK, exchange)
+}
+
+func (server *Server) handleClearExchanges(writer http.ResponseWriter, _ *http.Request) {
+ server.store.clear()
+ writer.WriteHeader(http.StatusNoContent)
+}
+
+func (server *Server) handleEvents(writer http.ResponseWriter, request *http.Request) {
+ flusher, ok := writer.(http.Flusher)
+ if !ok {
+ http.Error(writer, "当前响应不支持流式刷新", http.StatusInternalServerError)
+ return
+ }
+ writer.Header().Set("Content-Type", "text/event-stream")
+ writer.Header().Set("Cache-Control", "no-cache")
+ writer.Header().Set("Connection", "keep-alive")
+ updates, unsubscribe := server.store.subscribe()
+ defer unsubscribe()
+ fmt.Fprint(writer, "event: ready\ndata: {}\n\n")
+ flusher.Flush()
+ heartbeat := time.NewTicker(15 * time.Second)
+ defer heartbeat.Stop()
+ for {
+ select {
+ case <-request.Context().Done():
+ return
+ case event, open := <-updates:
+ if !open {
+ return
+ }
+ payload, _ := json.Marshal(event)
+ fmt.Fprintf(writer, "event: update\ndata: %s\n\n", payload)
+ flusher.Flush()
+ case <-heartbeat.C:
+ fmt.Fprint(writer, ": heartbeat\n\n")
+ flusher.Flush()
+ }
+ }
+}
+
+func (server *Server) handleCACertificate(writer http.ResponseWriter, _ *http.Request) {
+ writer.Header().Set("Content-Type", "application/x-x509-ca-cert")
+ writer.Header().Set("Content-Disposition", `attachment; filename="cursor-local-proxy-ca.crt"`)
+ _, _ = writer.Write(certs.EmbeddedCACertPEM())
+}
+
+func writeJSON(writer http.ResponseWriter, status int, payload any) {
+ writer.Header().Set("Content-Type", "application/json; charset=utf-8")
+ writer.WriteHeader(status)
+ _ = json.NewEncoder(writer).Encode(payload)
+}
+
+func securityHeaders(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
+ writer.Header().Set("X-Content-Type-Options", "nosniff")
+ writer.Header().Set("Referrer-Policy", "no-referrer")
+ writer.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'")
+ next.ServeHTTP(writer, request)
+ })
+}
diff --git a/cursor-proxy-debugger/web/app.js b/cursor-proxy-debugger/web/app.js
new file mode 100644
index 0000000..b854047
--- /dev/null
+++ b/cursor-proxy-debugger/web/app.js
@@ -0,0 +1,452 @@
+import { getLocale, setLocale, t, translateDocument } from "./i18n.js";
+
+const state = {
+ status: null,
+ exchanges: [],
+ selectedId: null,
+ selected: null,
+ search: "",
+ requestId: "",
+ endpoint: "all",
+ sortOrder: "desc",
+ paused: false,
+ pendingRefresh: false,
+ connection: { connected: false, key: "status.connecting", values: {} },
+ tabs: {
+ request: "body",
+ response: "frames",
+ },
+};
+
+const elements = {
+ statusDot: document.querySelector("#status-dot"),
+ statusText: document.querySelector("#status-text"),
+ proxyAddress: document.querySelector("#proxy-address"),
+ targetHost: document.querySelector("#target-host"),
+ connectionLabel: document.querySelector("#connection-label"),
+ trafficSummary: document.querySelector("#traffic-summary"),
+ searchInput: document.querySelector("#search-input"),
+ requestIdInput: document.querySelector("#request-id-input"),
+ endpointFilter: document.querySelector("#endpoint-filter"),
+ sortOrder: document.querySelector("#sort-order"),
+ requestCount: document.querySelector("#request-count"),
+ requestList: document.querySelector("#request-list"),
+ emptyState: document.querySelector("#empty-state"),
+ selectionSummary: document.querySelector("#selection-summary"),
+ requestContent: document.querySelector("#request-content"),
+ responseContent: document.querySelector("#response-content"),
+ pauseButton: document.querySelector("#pause-button"),
+ clearButton: document.querySelector("#clear-button"),
+ localeSelect: document.querySelector("#locale-select"),
+ workspace: document.querySelector("#workspace"),
+ splitter: document.querySelector("#horizontal-splitter"),
+};
+
+async function fetchJSON(url, options) {
+ const response = await fetch(url, options);
+ if (!response.ok) {
+ throw new Error(`${response.status} ${response.statusText}`);
+ }
+ if (response.status === 204) return null;
+ return response.json();
+}
+
+async function loadStatus() {
+ state.status = await fetchJSON("/api/status");
+ elements.statusDot.classList.toggle("online", Boolean(state.status.running));
+ renderRuntimeStatus();
+ elements.proxyAddress.textContent = `http://${state.status.proxyAddr}`;
+ elements.targetHost.textContent = state.status.targetHost;
+}
+
+async function refreshList() {
+ state.exchanges = await fetchJSON("/api/exchanges");
+ renderList();
+ renderTrafficSummary();
+ if (state.selectedId && state.exchanges.some((item) => item.id === state.selectedId)) {
+ await refreshDetail(state.selectedId);
+ } else if (state.selectedId) {
+ state.selectedId = null;
+ state.selected = null;
+ renderDetail();
+ }
+}
+
+async function refreshDetail(id) {
+ if (!id) return;
+ try {
+ const detail = await fetchJSON(`/api/exchanges/${encodeURIComponent(id)}`);
+ if (state.selectedId !== id) return;
+ state.selected = detail;
+ renderDetail();
+ } catch (error) {
+ if (state.selectedId === id) {
+ state.selected = null;
+ renderDetailError(error);
+ }
+ }
+}
+
+function scheduleRefresh() {
+ if (state.paused) {
+ state.pendingRefresh = true;
+ return;
+ }
+ if (state.pendingRefresh) return;
+ state.pendingRefresh = true;
+ window.setTimeout(async () => {
+ state.pendingRefresh = false;
+ try {
+ await refreshList();
+ } catch (error) {
+ setConnectionState(false, "connection.refreshFailed", { message: error.message });
+ }
+ }, 90);
+}
+
+function connectEvents() {
+ const events = new EventSource("/api/events");
+ events.addEventListener("open", () => setConnectionState(true, "connection.live"));
+ events.addEventListener("update", scheduleRefresh);
+ events.addEventListener("error", () => setConnectionState(false, "connection.retrying"));
+}
+
+function setConnectionState(connected, key, values = {}) {
+ state.connection = { connected, key, values };
+ renderConnectionState();
+}
+
+function renderRuntimeStatus() {
+ if (!state.status) {
+ elements.statusText.textContent = t("status.connecting");
+ return;
+ }
+ elements.statusText.textContent = t(state.status.running ? "status.running" : "status.stopped");
+}
+
+function renderConnectionState() {
+ const { connected, key, values } = state.connection;
+ elements.connectionLabel.textContent = t(key, values);
+ elements.statusDot.classList.toggle("online", connected && Boolean(state.status?.running));
+}
+
+function filteredExchanges() {
+ const query = state.search.trim().toLowerCase();
+ const requestId = state.requestId.trim().toLowerCase();
+ const direction = state.sortOrder === "asc" ? 1 : -1;
+ return state.exchanges
+ .filter((item) => {
+ if (state.endpoint === "runsse" && !item.path.toLowerCase().includes("runsse")) return false;
+ if (state.endpoint === "bidiappend" && !item.path.toLowerCase().includes("bidiappend")) return false;
+ if (requestId && !String(item.requestId || "").toLowerCase().includes(requestId)) return false;
+ if (!query) return true;
+ return [item.url, item.requestId, item.requestKind, item.responseKind, item.state, String(item.status)]
+ .filter(Boolean)
+ .some((value) => String(value).toLowerCase().includes(query));
+ })
+ .sort((left, right) => {
+ const startedAtDelta = new Date(left.startedAt).getTime() - new Date(right.startedAt).getTime();
+ if (startedAtDelta !== 0) return startedAtDelta * direction;
+ return left.id.localeCompare(right.id, undefined, { numeric: true }) * direction;
+ });
+}
+
+function renderList() {
+ const exchanges = filteredExchanges();
+ elements.requestCount.textContent = t("count.requests", { count: exchanges.length });
+ elements.emptyState.classList.toggle("hidden", exchanges.length > 0);
+ elements.requestList.innerHTML = exchanges
+ .map((item) => {
+ const selected = item.id === state.selectedId ? " selected" : "";
+ const statusClass = item.status >= 400 ? "error" : item.status ? "success" : "";
+ const kind = item.responseKind || item.requestKind || "-";
+ return `
+ |
+ ${escapeHTML(item.id)} |
+ ${escapeHTML(item.url)} |
+ ${escapeHTML(item.requestId || "-")} |
+ ${escapeHTML(kind)} |
+ ${escapeHTML(item.method)} |
+ ${item.status || "-"} |
+ ${formatBytes(item.responseBytes)} |
+ ${formatDuration(item.durationMs)} |
+
`;
+ })
+ .join("");
+}
+
+function renderTrafficSummary() {
+ const totals = state.exchanges.reduce(
+ (result, item) => {
+ result.up += item.requestBytes || 0;
+ result.down += item.responseBytes || 0;
+ return result;
+ },
+ { up: 0, down: 0 },
+ );
+ elements.trafficSummary.textContent = `↑ ${formatBytes(totals.up)} ↓ ${formatBytes(totals.down)}`;
+}
+
+function renderDetail() {
+ if (!state.selected) {
+ elements.selectionSummary.innerHTML = `POST${escapeHTML(t("selection.waiting"))}${escapeHTML(t("selection.prompt"))}`;
+ elements.requestContent.innerHTML = `${escapeHTML(t("notices.noRequest"))}
`;
+ elements.responseContent.innerHTML = `${escapeHTML(t("notices.noResponse"))}
`;
+ return;
+ }
+ const item = state.selected;
+ const statusClass = item.status >= 200 && item.status < 400 ? "success" : "";
+ elements.selectionSummary.innerHTML = `${escapeHTML(item.method)}${escapeHTML(item.status || formatState(item.state))}${escapeHTML(item.url)}`;
+ elements.requestContent.innerHTML = renderPayload(item.request, state.tabs.request);
+ elements.responseContent.innerHTML = renderPayload(item.response, state.tabs.response);
+}
+
+function renderDetailError(error) {
+ elements.requestContent.innerHTML = `${escapeHTML(error.message)}
`;
+ elements.responseContent.innerHTML = `${escapeHTML(error.message)}
`;
+}
+
+function renderPayload(payload, tab) {
+ if (!payload) return `${escapeHTML(t("notices.noContent"))}
`;
+ if (tab === "headers") return renderHeaders(payload.headers);
+ if (tab === "frames") return renderFrames(payload.frames);
+ if (tab === "raw") {
+ const body = payload.rawHex ? formatHex(payload.rawHex) : t("notices.noRaw");
+ return `${escapeHTML(body)}${renderTruncated(payload.rawTruncated)}`;
+ }
+ if (payload.decodedJson) {
+ return `${escapeHTML(payload.decodedJson)}${renderDecodeError(payload.decodeError)}${renderTruncated(payload.rawTruncated)}`;
+ }
+ if (payload.frames?.length) return renderFrames(payload.frames);
+ if (payload.decodeError) return `${escapeHTML(payload.decodeError)}
`;
+ return `${escapeHTML(t("notices.noBody"))}
`;
+}
+
+function renderHeaders(headers = []) {
+ const items = Array.isArray(headers) ? headers : [];
+ if (!items.length) return `${escapeHTML(t("notices.noHeaders"))}
`;
+ return ``;
+}
+
+function renderFrames(frames = []) {
+ const items = Array.isArray(frames) ? frames : [];
+ if (!items.length) return `${escapeHTML(t("notices.noFrames"))}
`;
+ return `${items
+ .map((frame) => {
+ const kind = frame.kind || frame.messageType || t("notices.unknown");
+ const flags = `0x${Number(frame.flags || 0).toString(16).padStart(2, "0")}`;
+ const content = frame.json
+ ? `
${escapeHTML(frame.json)}`
+ : `
${escapeHTML(formatHex(frame.rawHex || ""))}`;
+ return `
+
+ #${frame.index}
+ ${escapeHTML(kind)}
+ ${formatBytes(frame.length)}
+ ${flags}${frame.compressed ? " gzip" : ""}
+
+ ${frame.error ? `${escapeHTML(frame.error)}
` : content}
+ `;
+ })
+ .join("")}
`;
+}
+
+function renderDecodeError(error) {
+ return error ? `${escapeHTML(error)}
` : "";
+}
+
+function renderTruncated(truncated) {
+ return truncated ? `${escapeHTML(t("notices.truncated"))}
` : "";
+}
+
+function formatState(value) {
+ const key = {
+ pending: "state.pending",
+ streaming: "state.streaming",
+ completed: "state.completed",
+ error: "state.error",
+ }[value];
+ return key ? t(key) : value || "-";
+}
+
+function currentCopyText(side) {
+ const payload = state.selected?.[side];
+ if (!payload) return "";
+ const tab = state.tabs[side];
+ if (tab === "headers") return (payload.headers || []).map((item) => `${item.name}: ${item.value}`).join("\n");
+ if (tab === "raw") return payload.rawHex || "";
+ if (tab === "frames") return (payload.frames || []).map((frame) => frame.json || frame.rawHex || frame.error || "").join("\n\n");
+ return payload.decodedJson || "";
+}
+
+function formatHex(value) {
+ const hex = String(value || "").replace(/[^0-9a-f]/gi, "");
+ const lines = [];
+ for (let index = 0; index < hex.length; index += 32) {
+ const chunk = hex.slice(index, index + 32);
+ const bytes = chunk.match(/.{1,2}/g) || [];
+ lines.push(`${(index / 2).toString(16).padStart(8, "0")} ${bytes.join(" ")}`);
+ }
+ return lines.join("\n");
+}
+
+function formatBytes(value) {
+ const bytes = Number(value || 0);
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
+}
+
+function formatDuration(value) {
+ const milliseconds = Number(value || 0);
+ if (milliseconds < 1000) return `${milliseconds} ms`;
+ return `${(milliseconds / 1000).toFixed(1)} s`;
+}
+
+function escapeHTML(value) {
+ return String(value ?? "")
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+}
+
+elements.requestList.addEventListener("click", async (event) => {
+ const row = event.target.closest("tr[data-id]");
+ if (!row) return;
+ state.selectedId = row.dataset.id;
+ state.selected = null;
+ renderList();
+ renderDetail();
+ await refreshDetail(state.selectedId);
+});
+
+elements.searchInput.addEventListener("input", (event) => {
+ state.search = event.target.value;
+ renderList();
+});
+
+elements.requestIdInput.addEventListener("input", (event) => {
+ state.requestId = event.target.value;
+ renderList();
+});
+
+elements.endpointFilter.addEventListener("click", (event) => {
+ const button = event.target.closest("button[data-value]");
+ if (!button) return;
+ state.endpoint = button.dataset.value;
+ for (const item of elements.endpointFilter.querySelectorAll("button")) {
+ item.classList.toggle("active", item === button);
+ }
+ renderList();
+});
+
+elements.sortOrder.addEventListener("click", (event) => {
+ const button = event.target.closest("button[data-value]");
+ if (!button) return;
+ state.sortOrder = button.dataset.value;
+ for (const item of elements.sortOrder.querySelectorAll("button")) {
+ item.classList.toggle("active", item === button);
+ }
+ renderList();
+});
+
+document.querySelectorAll(".payload-panel").forEach((panel) => {
+ panel.querySelector(".tabs").addEventListener("click", (event) => {
+ const button = event.target.closest("button[data-tab]");
+ if (!button) return;
+ const side = panel.dataset.side;
+ state.tabs[side] = button.dataset.tab;
+ panel.querySelectorAll(".tabs button").forEach((item) => item.classList.toggle("active", item === button));
+ renderDetail();
+ });
+});
+
+document.querySelectorAll("[data-copy-side]").forEach((button) => {
+ button.addEventListener("click", async () => {
+ const text = currentCopyText(button.dataset.copySide);
+ if (!text) return;
+ await navigator.clipboard.writeText(text);
+ button.textContent = t("actions.copied");
+ window.setTimeout(() => {
+ button.textContent = t("actions.copy");
+ }, 900);
+ });
+});
+
+function renderPauseState() {
+ elements.pauseButton.textContent = state.paused ? "▶" : "Ⅱ";
+ const actionKey = state.paused ? "actions.resume" : "actions.pause";
+ elements.pauseButton.title = t(actionKey);
+ elements.pauseButton.setAttribute("aria-label", t(actionKey));
+}
+
+elements.pauseButton.addEventListener("click", async () => {
+ state.paused = !state.paused;
+ elements.pauseButton.classList.toggle("active", state.paused);
+ renderPauseState();
+ setConnectionState(!state.paused, state.paused ? "connection.paused" : "connection.live");
+ if (!state.paused && state.pendingRefresh) {
+ state.pendingRefresh = false;
+ await refreshList();
+ }
+});
+
+function applyLocale() {
+ translateDocument();
+ elements.localeSelect.value = getLocale();
+ renderRuntimeStatus();
+ renderConnectionState();
+ renderPauseState();
+ renderList();
+ renderTrafficSummary();
+ renderDetail();
+}
+
+elements.localeSelect.addEventListener("change", (event) => {
+ setLocale(event.target.value);
+ applyLocale();
+});
+
+elements.clearButton.addEventListener("click", async () => {
+ await fetchJSON("/api/exchanges", { method: "DELETE" });
+ state.selectedId = null;
+ state.selected = null;
+ await refreshList();
+ renderDetail();
+});
+
+let draggingSplitter = false;
+elements.splitter.addEventListener("pointerdown", (event) => {
+ draggingSplitter = true;
+ elements.splitter.classList.add("dragging");
+ elements.splitter.setPointerCapture(event.pointerId);
+});
+
+elements.splitter.addEventListener("pointermove", (event) => {
+ if (!draggingSplitter) return;
+ const bounds = elements.workspace.getBoundingClientRect();
+ const top = Math.max(180, Math.min(bounds.height - 225, event.clientY - bounds.top));
+ elements.workspace.style.gridTemplateRows = `${top}px 5px minmax(220px, 1fr)`;
+});
+
+elements.splitter.addEventListener("pointerup", () => {
+ draggingSplitter = false;
+ elements.splitter.classList.remove("dragging");
+});
+
+async function bootstrap() {
+ applyLocale();
+ renderDetail();
+ try {
+ await Promise.all([loadStatus(), refreshList()]);
+ connectEvents();
+ } catch (error) {
+ setConnectionState(false, "connection.connectFailed", { message: error.message });
+ }
+}
+
+void bootstrap();
diff --git a/cursor-proxy-debugger/web/i18n.js b/cursor-proxy-debugger/web/i18n.js
new file mode 100644
index 0000000..ebfd5fd
--- /dev/null
+++ b/cursor-proxy-debugger/web/i18n.js
@@ -0,0 +1,178 @@
+const SOURCE_LOCALE = "zh-CN";
+const DEFAULT_LOCALE = "en-US";
+const STORAGE_KEY = "cursor-proxy-debugger:locale:v1";
+const SUPPORTED_LOCALES = [SOURCE_LOCALE, DEFAULT_LOCALE];
+
+const messages = {
+ "zh-CN": {
+ "app.title": "Cursor 协议调试器",
+ "status.connecting": "正在连接",
+ "status.running": "代理运行中",
+ "status.stopped": "代理已停止",
+ "actions.downloadCA": "下载代理 CA 证书",
+ "actions.caCertificate": "CA 证书",
+ "actions.pause": "暂停界面更新",
+ "actions.resume": "继续界面更新",
+ "actions.clear": "清空",
+ "actions.copy": "复制",
+ "actions.copied": "已复制",
+ "language.label": "界面语言",
+ "filters.region": "请求过滤器",
+ "filters.urlPlaceholder": "过滤 URL、请求类型或状态",
+ "filters.requestIdPlaceholder": "按 Request ID 过滤",
+ "filters.endpoint": "接口过滤",
+ "filters.all": "全部",
+ "filters.sort": "排序方向",
+ "filters.ascending": "正序",
+ "filters.descending": "倒序",
+ "count.requests": "{count} 条",
+ "table.url": "网址",
+ "table.message": "消息",
+ "table.method": "方法",
+ "table.status": "状态",
+ "table.response": "响应",
+ "table.duration": "耗时",
+ "empty.waitingForCursor": "等待来自 Cursor 的请求",
+ "splitter.resize": "调整详情区域高度",
+ "selection.waiting": "等待选择",
+ "selection.prompt": "选择一条请求查看详情",
+ "panel.request": "请求",
+ "panel.response": "响应",
+ "panel.requestDetails": "请求详情",
+ "panel.responseDetails": "响应详情",
+ "tabs.headers": "标头",
+ "tabs.body": "正文",
+ "tabs.frames": "帧",
+ "tabs.raw": "原始",
+ "notices.noRequest": "暂无请求内容",
+ "notices.noResponse": "暂无响应内容",
+ "notices.noContent": "暂无内容",
+ "notices.noRaw": "暂无原始数据",
+ "notices.noBody": "暂无可显示的正文",
+ "notices.noHeaders": "暂无标头",
+ "notices.noFrames": "尚未收到完整帧",
+ "notices.unknown": "未识别",
+ "notices.truncated": "原始正文已达到本地抓取上限,转发内容未被截断",
+ "connection.live": "实时连接中",
+ "connection.retrying": "实时连接正在重试",
+ "connection.refreshFailed": "刷新失败:{message}",
+ "connection.paused": "界面更新已暂停",
+ "connection.connectFailed": "连接失败:{message}",
+ "state.pending": "等待中",
+ "state.streaming": "传输中",
+ "state.completed": "已完成",
+ "state.error": "错误",
+ },
+ "en-US": {
+ "app.title": "Cursor Protocol Debugger",
+ "status.connecting": "Connecting",
+ "status.running": "Proxy running",
+ "status.stopped": "Proxy stopped",
+ "actions.downloadCA": "Download proxy CA certificate",
+ "actions.caCertificate": "CA Certificate",
+ "actions.pause": "Pause UI updates",
+ "actions.resume": "Resume UI updates",
+ "actions.clear": "Clear",
+ "actions.copy": "Copy",
+ "actions.copied": "Copied",
+ "language.label": "Interface language",
+ "filters.region": "Request filters",
+ "filters.urlPlaceholder": "Filter by URL, message type, or status",
+ "filters.requestIdPlaceholder": "Filter by Request ID",
+ "filters.endpoint": "Endpoint filter",
+ "filters.all": "All",
+ "filters.sort": "Sort order",
+ "filters.ascending": "Oldest first",
+ "filters.descending": "Newest first",
+ "count.requests": "{count} requests",
+ "table.url": "URL",
+ "table.message": "Message",
+ "table.method": "Method",
+ "table.status": "Status",
+ "table.response": "Response",
+ "table.duration": "Duration",
+ "empty.waitingForCursor": "Waiting for requests from Cursor",
+ "splitter.resize": "Resize details area",
+ "selection.waiting": "No selection",
+ "selection.prompt": "Select a request to inspect its details",
+ "panel.request": "Request",
+ "panel.response": "Response",
+ "panel.requestDetails": "Request details",
+ "panel.responseDetails": "Response details",
+ "tabs.headers": "Headers",
+ "tabs.body": "Body",
+ "tabs.frames": "Frames",
+ "tabs.raw": "Raw",
+ "notices.noRequest": "No request content",
+ "notices.noResponse": "No response content",
+ "notices.noContent": "No content",
+ "notices.noRaw": "No raw data",
+ "notices.noBody": "No body available",
+ "notices.noHeaders": "No headers",
+ "notices.noFrames": "No complete frames received yet",
+ "notices.unknown": "Unknown",
+ "notices.truncated": "Raw body reached the local capture limit; forwarded data was not truncated",
+ "connection.live": "Live connection",
+ "connection.retrying": "Reconnecting live updates",
+ "connection.refreshFailed": "Refresh failed: {message}",
+ "connection.paused": "UI updates paused",
+ "connection.connectFailed": "Connection failed: {message}",
+ "state.pending": "Pending",
+ "state.streaming": "Streaming",
+ "state.completed": "Completed",
+ "state.error": "Error",
+ },
+};
+
+function matchLocale(locale) {
+ const normalized = String(locale || "").trim().replaceAll("_", "-").toLowerCase();
+ if (!normalized) return "";
+ const exact = SUPPORTED_LOCALES.find((candidate) => candidate.toLowerCase() === normalized);
+ if (exact) return exact;
+ return normalized.split("-")[0] === "zh" ? SOURCE_LOCALE : normalized.split("-")[0] === "en" ? DEFAULT_LOCALE : "";
+}
+
+function resolveInitialLocale() {
+ const stored = matchLocale(window.localStorage.getItem(STORAGE_KEY));
+ if (stored) return stored;
+ for (const candidate of navigator.languages || [navigator.language]) {
+ const matched = matchLocale(candidate);
+ if (matched) return matched;
+ }
+ return DEFAULT_LOCALE;
+}
+
+let currentLocale = resolveInitialLocale();
+
+export function getLocale() {
+ return currentLocale;
+}
+
+export function t(key, values = {}) {
+ const template = messages[currentLocale]?.[key] || messages[SOURCE_LOCALE][key] || key;
+ return template.replace(/\{(\w+)\}/g, (_match, name) => String(values[name] ?? ""));
+}
+
+export function translateDocument(root = document) {
+ document.documentElement.lang = currentLocale;
+ document.title = t("app.title");
+ for (const element of root.querySelectorAll("[data-i18n]")) {
+ element.textContent = t(element.dataset.i18n);
+ }
+ for (const [attribute, dataAttribute] of [
+ ["aria-label", "i18nAriaLabel"],
+ ["placeholder", "i18nPlaceholder"],
+ ["title", "i18nTitle"],
+ ]) {
+ for (const element of root.querySelectorAll(`[data-${dataAttribute.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}]`)) {
+ element.setAttribute(attribute, t(element.dataset[dataAttribute]));
+ }
+ }
+}
+
+export function setLocale(locale) {
+ currentLocale = matchLocale(locale) || DEFAULT_LOCALE;
+ window.localStorage.setItem(STORAGE_KEY, currentLocale);
+ translateDocument();
+ return currentLocale;
+}
diff --git a/cursor-proxy-debugger/web/index.html b/cursor-proxy-debugger/web/index.html
new file mode 100644
index 0000000..34eddb4
--- /dev/null
+++ b/cursor-proxy-debugger/web/index.html
@@ -0,0 +1,125 @@
+
+
+
+
+
+
+ Cursor 协议调试器
+
+
+
+
+
+
+
+ Cursor 协议调试器
+
+
+
+ 正在连接
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ # |
+ 网址 |
+ Request ID |
+ 消息 |
+ 方法 |
+ 状态 |
+ 响应 |
+ 耗时 |
+
+
+
+
+ 等待来自 Cursor 的请求
+
+
+
+
+
+
+ POST
+ 等待选择
+ 选择一条请求查看详情
+
+
+
+
+
+
+
+
+
+
diff --git a/cursor-proxy-debugger/web/styles.css b/cursor-proxy-debugger/web/styles.css
new file mode 100644
index 0000000..0f1082b
--- /dev/null
+++ b/cursor-proxy-debugger/web/styles.css
@@ -0,0 +1,921 @@
+:root {
+ color-scheme: dark;
+ font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ background: #171818;
+ color: #dedfdd;
+ font-synthesis: none;
+ --surface-0: #171818;
+ --surface-1: #1d1f1f;
+ --surface-2: #242626;
+ --surface-3: #2c2f2f;
+ --border: #343737;
+ --border-strong: #454949;
+ --muted: #8d9390;
+ --text: #dedfdd;
+ --accent: #4ea58b;
+ --accent-soft: #25473d;
+ --cyan: #55a8ba;
+ --orange: #c88762;
+ --danger: #c56d65;
+ --selection: #245b73;
+ --mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body,
+#app {
+ width: 100%;
+ height: 100%;
+ margin: 0;
+ overflow: hidden;
+}
+
+body {
+ background: var(--surface-0);
+}
+
+button,
+input,
+select,
+a {
+ font: inherit;
+ letter-spacing: 0;
+}
+
+button,
+a {
+ -webkit-tap-highlight-color: transparent;
+}
+
+button:focus-visible,
+input:focus-visible,
+select:focus-visible,
+a:focus-visible {
+ outline: 2px solid var(--cyan);
+ outline-offset: -1px;
+}
+
+.app-shell {
+ display: grid;
+ grid-template-rows: 48px 46px minmax(0, 1fr) 26px;
+ min-width: 760px;
+ background: var(--surface-0);
+}
+
+.topbar,
+.filterbar,
+.statusbar {
+ display: flex;
+ align-items: center;
+ border-color: var(--border);
+ background: var(--surface-1);
+}
+
+.topbar {
+ justify-content: space-between;
+ gap: 18px;
+ padding: 0 14px;
+ border-bottom: 1px solid var(--border);
+}
+
+.brand,
+.runtime-status,
+.toolbar-actions {
+ display: flex;
+ align-items: center;
+ min-width: 0;
+}
+
+.brand {
+ gap: 9px;
+ white-space: nowrap;
+}
+
+.brand strong {
+ font-size: 14px;
+ font-weight: 650;
+}
+
+.brand-mark {
+ width: 12px;
+ height: 12px;
+ border: 2px solid var(--accent);
+ border-radius: 50%;
+ box-shadow: inset 0 0 0 2px var(--surface-1);
+ background: var(--accent);
+}
+
+.runtime-status {
+ justify-content: center;
+ gap: 7px;
+ min-width: 240px;
+ color: #bec3c0;
+ font-size: 12px;
+}
+
+.runtime-status code {
+ overflow: hidden;
+ max-width: 260px;
+ color: var(--muted);
+ font-family: var(--mono);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.status-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: #7d8380;
+}
+
+.status-dot.online {
+ background: #42bd79;
+ box-shadow: 0 0 0 3px rgb(66 189 121 / 14%);
+}
+
+.toolbar-actions {
+ justify-content: flex-end;
+ gap: 7px;
+}
+
+.locale-picker {
+ display: flex;
+}
+
+.locale-picker select {
+ width: 58px;
+ height: 29px;
+ border: 1px solid var(--border-strong);
+ border-radius: 5px;
+ padding: 0 6px;
+ background: var(--surface-2);
+ color: var(--text);
+ cursor: pointer;
+ font-size: 12px;
+}
+
+.visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+.button,
+.icon-button,
+.copy-button {
+ height: 29px;
+ border: 1px solid var(--border-strong);
+ border-radius: 5px;
+ background: var(--surface-2);
+ color: var(--text);
+ cursor: pointer;
+ text-decoration: none;
+}
+
+.button {
+ display: inline-flex;
+ align-items: center;
+ padding: 0 10px;
+ font-size: 12px;
+}
+
+.button:hover,
+.icon-button:hover,
+.copy-button:hover {
+ background: var(--surface-3);
+}
+
+.button.danger:hover {
+ border-color: #744640;
+ color: #f2b0aa;
+}
+
+.icon-button {
+ width: 31px;
+ padding: 0;
+ font-family: var(--mono);
+ font-weight: 700;
+}
+
+.icon-button.active {
+ border-color: var(--orange);
+ color: #f1bb98;
+}
+
+.filterbar {
+ gap: 10px;
+ padding: 7px 14px;
+ border-bottom: 1px solid var(--border);
+}
+
+.search-box {
+ display: flex;
+ align-items: center;
+ flex: 1;
+ min-width: 260px;
+ max-width: 640px;
+ height: 31px;
+ border: 1px solid var(--border);
+ border-radius: 5px;
+ background: #191b1b;
+ color: var(--muted);
+}
+
+.search-box > span {
+ padding-left: 9px;
+ font-size: 17px;
+}
+
+.search-box input {
+ flex: 1;
+ min-width: 0;
+ height: 100%;
+ border: 0;
+ padding: 0 9px;
+ outline: 0;
+ background: transparent;
+ color: var(--text);
+ font-size: 12px;
+}
+
+.search-box input::placeholder {
+ color: #6f7572;
+}
+
+.url-filter {
+ min-width: 280px;
+ max-width: 420px;
+}
+
+.request-id-filter {
+ flex: 0 1 320px;
+ min-width: 220px;
+ max-width: 340px;
+}
+
+.segmented-control {
+ display: flex;
+ height: 31px;
+ border: 1px solid var(--border);
+ border-radius: 5px;
+ overflow: hidden;
+}
+
+.segmented-control button {
+ min-width: 62px;
+ border: 0;
+ border-right: 1px solid var(--border);
+ padding: 0 10px;
+ background: #1b1d1d;
+ color: var(--muted);
+ cursor: pointer;
+ font-size: 12px;
+}
+
+.segmented-control button:last-child {
+ border-right: 0;
+}
+
+.segmented-control button.active {
+ background: var(--accent-soft);
+ color: #bce8d9;
+}
+
+.sort-control button {
+ min-width: 52px;
+}
+
+.request-count {
+ margin-left: auto;
+ color: var(--muted);
+ font-family: var(--mono);
+ font-size: 11px;
+ white-space: nowrap;
+}
+
+.workspace {
+ display: grid;
+ grid-template-rows: minmax(180px, 52%) 5px minmax(220px, 48%);
+ min-height: 0;
+ overflow: hidden;
+}
+
+.request-list-pane {
+ position: relative;
+ min-height: 0;
+ overflow: auto;
+ background: #181a1a;
+}
+
+.request-table {
+ width: 100%;
+ border-collapse: collapse;
+ table-layout: fixed;
+ font-size: 12px;
+}
+
+.request-table thead {
+ position: sticky;
+ top: 0;
+ z-index: 2;
+ background: #202222;
+}
+
+.request-table th,
+.request-table td {
+ height: 30px;
+ border-right: 1px solid #2c2f2f;
+ border-bottom: 1px solid #292c2c;
+ padding: 0 9px;
+ overflow: hidden;
+ text-align: left;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.request-table th {
+ color: #9da29f;
+ font-weight: 550;
+}
+
+.request-table tbody tr {
+ cursor: default;
+}
+
+.request-table tbody tr:hover {
+ background: #222525;
+}
+
+.request-table tbody tr.selected {
+ background: var(--selection);
+ color: #f3f8f8;
+}
+
+.request-table code {
+ font-family: var(--mono);
+}
+
+.status-column {
+ width: 30px;
+}
+
+.index-column {
+ width: 54px;
+}
+
+.request-id-column {
+ width: 250px;
+}
+
+.kind-column {
+ width: 180px;
+}
+
+.method-column {
+ width: 72px;
+}
+
+.code-column {
+ width: 66px;
+}
+
+.size-column {
+ width: 86px;
+}
+
+.time-column {
+ width: 74px;
+}
+
+.row-state {
+ display: block;
+ width: 8px;
+ height: 8px;
+ margin: auto;
+ border-radius: 50%;
+ background: #7c8380;
+}
+
+.row-state.streaming {
+ background: #45ba77;
+}
+
+.row-state.completed {
+ background: var(--cyan);
+}
+
+.row-state.error {
+ background: var(--danger);
+}
+
+.method-text {
+ color: #61b9df;
+ font-family: var(--mono);
+ font-weight: 650;
+}
+
+.status-text.success {
+ color: #68c991;
+}
+
+.status-text.error {
+ color: #e18b83;
+}
+
+.kind-text {
+ color: #d3a17f;
+ font-family: var(--mono);
+}
+
+.request-id-text {
+ color: #8bc2cc;
+}
+
+.empty-state {
+ position: absolute;
+ inset: 34px 0 0;
+ display: grid;
+ place-items: center;
+ color: #686e6b;
+ font-size: 13px;
+}
+
+.empty-state.hidden {
+ display: none;
+}
+
+.horizontal-splitter {
+ cursor: row-resize;
+ background: #343737;
+}
+
+.horizontal-splitter:hover,
+.horizontal-splitter.dragging {
+ background: var(--cyan);
+}
+
+.detail-pane {
+ display: grid;
+ grid-template-rows: 38px minmax(0, 1fr);
+ min-height: 0;
+ background: var(--surface-0);
+}
+
+.selection-summary {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+ padding: 0 14px;
+ border-bottom: 1px solid var(--border);
+ background: #1b1d1d;
+}
+
+.selection-summary code {
+ overflow: hidden;
+ color: #aeb4b1;
+ font-family: var(--mono);
+ font-size: 12px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.method-badge,
+.status-badge,
+.frame-badge {
+ display: inline-flex;
+ align-items: center;
+ height: 22px;
+ border: 1px solid var(--border-strong);
+ border-radius: 4px;
+ padding: 0 7px;
+ font-family: var(--mono);
+ font-size: 11px;
+ white-space: nowrap;
+}
+
+.method-badge {
+ border-color: #34667a;
+ color: #74c8e8;
+}
+
+.status-badge.success {
+ border-color: #3f7157;
+ color: #83d5a5;
+}
+
+.detail-columns {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+ min-height: 0;
+}
+
+.payload-panel {
+ display: grid;
+ grid-template-rows: 36px minmax(0, 1fr);
+ min-width: 0;
+ min-height: 0;
+ border-right: 1px solid var(--border);
+}
+
+.payload-panel:last-child {
+ border-right: 0;
+}
+
+.panel-header {
+ display: flex;
+ align-items: center;
+ min-width: 0;
+ border-bottom: 1px solid var(--border);
+ background: #202222;
+}
+
+.panel-header > strong {
+ padding: 0 10px;
+ color: #c9cdca;
+ font-size: 12px;
+}
+
+.tabs {
+ display: flex;
+ align-self: stretch;
+}
+
+.tabs button {
+ position: relative;
+ min-width: 46px;
+ border: 0;
+ padding: 0 9px;
+ background: transparent;
+ color: var(--muted);
+ cursor: pointer;
+ font-size: 12px;
+}
+
+.tabs button:hover {
+ color: #d7dad8;
+}
+
+.tabs button.active {
+ color: #71c7e2;
+}
+
+.tabs button.active::after {
+ position: absolute;
+ right: 8px;
+ bottom: 0;
+ left: 8px;
+ height: 2px;
+ background: var(--cyan);
+ content: "";
+}
+
+.copy-button {
+ width: 48px;
+ height: 24px;
+ margin-right: 7px;
+ margin-left: auto;
+ font-size: 11px;
+}
+
+.panel-content {
+ min-height: 0;
+ overflow: auto;
+ background: #181a1a;
+}
+
+.code-view,
+.hex-view {
+ min-width: 100%;
+ min-height: 100%;
+ margin: 0;
+ padding: 12px 14px 30px;
+ color: #ccd1ce;
+ font: 11px/1.55 var(--mono);
+ tab-size: 2;
+ white-space: pre;
+}
+
+.hex-view {
+ color: #b6c2bd;
+}
+
+.headers-table {
+ width: 100%;
+ border-collapse: collapse;
+ font: 11px/1.4 var(--mono);
+}
+
+.headers-table th,
+.headers-table td {
+ border-bottom: 1px solid #292c2c;
+ padding: 7px 10px;
+ text-align: left;
+ vertical-align: top;
+}
+
+.headers-table th {
+ width: 38%;
+ color: #62b3cd;
+ font-weight: 500;
+ overflow-wrap: anywhere;
+}
+
+.headers-table td {
+ color: #c5c9c6;
+ overflow-wrap: anywhere;
+}
+
+.frame-list {
+ min-width: 480px;
+}
+
+.frame-item {
+ border-bottom: 1px solid #292c2c;
+}
+
+.frame-item summary {
+ display: grid;
+ grid-template-columns: 58px minmax(150px, 1fr) 90px 82px;
+ align-items: center;
+ height: 32px;
+ padding: 0 10px;
+ color: #c5cac7;
+ cursor: pointer;
+ font: 11px var(--mono);
+ list-style: none;
+}
+
+.frame-item summary::-webkit-details-marker {
+ display: none;
+}
+
+.frame-item summary:hover {
+ background: #222525;
+}
+
+.frame-item[open] summary {
+ background: #242727;
+}
+
+.frame-index {
+ color: #747b77;
+}
+
+.frame-kind {
+ overflow: hidden;
+ color: #dfaa85;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.frame-size,
+.frame-flags {
+ color: #7faeb7;
+ text-align: right;
+}
+
+.frame-error {
+ margin: 10px 14px;
+ color: #ec968e;
+ font: 11px/1.5 var(--mono);
+}
+
+.notice {
+ padding: 14px;
+ color: #7d8581;
+ font: 12px/1.6 var(--mono);
+}
+
+.notice.error {
+ color: #df8b83;
+}
+
+.truncated-notice {
+ position: sticky;
+ bottom: 0;
+ padding: 5px 10px;
+ border-top: 1px solid #674f3f;
+ background: #3d3028;
+ color: #e5b28e;
+ font-size: 11px;
+}
+
+.statusbar {
+ justify-content: flex-end;
+ gap: 16px;
+ padding: 0 10px;
+ border-top: 1px solid var(--border);
+ color: #848b87;
+ font: 10px var(--mono);
+}
+
+.statusbar span:first-child {
+ margin-right: auto;
+}
+
+@media (max-width: 920px) {
+ .app-shell {
+ grid-template-rows: 48px 84px minmax(0, 1fr) 26px;
+ min-width: 0;
+ }
+
+ .filterbar {
+ align-content: center;
+ flex-wrap: wrap;
+ gap: 6px;
+ }
+
+ .url-filter,
+ .request-id-filter {
+ flex: 1 1 300px;
+ max-width: none;
+ }
+
+ .runtime-status code,
+ .kind-column,
+ .request-table td:nth-child(5) {
+ display: none;
+ }
+
+ .detail-columns {
+ grid-template-columns: 1fr;
+ grid-template-rows: minmax(180px, 1fr) minmax(180px, 1fr);
+ overflow: auto;
+ }
+
+ .payload-panel {
+ min-height: 260px;
+ border-right: 0;
+ border-bottom: 1px solid var(--border);
+ }
+}
+
+@media (max-width: 640px) {
+ .app-shell {
+ grid-template-rows: 82px 122px minmax(0, 1fr) 26px;
+ }
+
+ .topbar {
+ position: relative;
+ align-content: center;
+ flex-wrap: wrap;
+ gap: 4px 10px;
+ padding: 8px 10px;
+ }
+
+ .brand {
+ flex: 1;
+ overflow: hidden;
+ }
+
+ .brand strong {
+ overflow: hidden;
+ font-size: 13px;
+ text-overflow: ellipsis;
+ }
+
+ .runtime-status {
+ order: 3;
+ justify-content: flex-start;
+ width: 100%;
+ min-width: 0;
+ }
+
+ .runtime-status code {
+ display: block;
+ max-width: none;
+ }
+
+ .toolbar-actions {
+ gap: 4px;
+ }
+
+ .toolbar-actions .button {
+ padding: 0 7px;
+ }
+
+ .filterbar {
+ align-content: center;
+ flex-wrap: wrap;
+ gap: 6px;
+ padding: 7px 10px;
+ }
+
+ .url-filter,
+ .request-id-filter {
+ flex: 0 0 100%;
+ width: 100%;
+ min-width: 0;
+ max-width: none;
+ }
+
+ .request-count {
+ order: 5;
+ margin-left: auto;
+ }
+
+ #endpoint-filter {
+ order: 3;
+ flex: 1;
+ }
+
+ .sort-control {
+ order: 4;
+ flex: 0 0 104px;
+ }
+
+ .segmented-control button {
+ flex: 1;
+ min-width: 0;
+ }
+
+ .workspace {
+ grid-template-rows: minmax(150px, 40%) 5px minmax(260px, 60%);
+ }
+
+ .request-table th,
+ .request-table td {
+ padding: 0 6px;
+ }
+
+ .request-table .index-column,
+ .request-table th:nth-child(2),
+ .request-table td:nth-child(2),
+ .size-column,
+ .request-table th:nth-child(8),
+ .request-table td:nth-child(8),
+ .time-column,
+ .request-table th:nth-child(9),
+ .request-table td:nth-child(9) {
+ display: none;
+ }
+
+ .request-id-column {
+ width: 130px;
+ }
+
+ .method-column {
+ width: 58px;
+ }
+
+ .code-column {
+ width: 50px;
+ }
+
+ .selection-summary {
+ padding: 0 8px;
+ }
+
+ .detail-columns {
+ grid-template-rows: minmax(220px, 1fr) minmax(220px, 1fr);
+ }
+
+ .panel-header > strong {
+ width: 72px;
+ padding: 0 7px;
+ font-size: 11px;
+ }
+
+ .tabs {
+ overflow-x: auto;
+ }
+
+ .tabs button {
+ min-width: 42px;
+ padding: 0 6px;
+ }
+
+ .copy-button {
+ width: 42px;
+ margin-right: 4px;
+ }
+
+ .frame-list {
+ min-width: 0;
+ }
+
+ .frame-item summary {
+ grid-template-columns: 42px minmax(100px, 1fr) 62px 72px;
+ padding: 0 7px;
+ }
+
+ .statusbar {
+ gap: 8px;
+ }
+
+ #target-host {
+ display: none;
+ }
+}