mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-08-17 03:27:02 +08:00
- Added support for capturing and decoding Fork Chat traffic, including `ForkBackgroundComposer`, `NotifyConversationClone`, and `UploadConversationBlobs`. - Updated the README to reflect new features and usage instructions for Fork Chat traffic. - Modified `.gitignore` to include `proto/extensions-cursor-app/`. - Refactored `Taskfile.yml` to improve error messages related to Cursor extensions. - Introduced new tests for decoding functionality in `cursor-proxy-debugger`.
295 lines
8.3 KiB
Go
295 lines
8.3 KiB
Go
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
|
|
|
|
const (
|
|
bidiAppendPath = "/aiserver.v1.BidiService/BidiAppend"
|
|
forkBackgroundComposerPath = "/aiserver.v1.BackgroundComposerService/ForkBackgroundComposer"
|
|
notifyConversationClonePath = "/agent.v1.AgentService/NotifyConversationClone"
|
|
uploadConversationBlobsPath = "/agent.v1.AgentService/UploadConversationBlobs"
|
|
)
|
|
|
|
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 decodeUnaryRequest(path string, payload []byte) (decodedJSON string, kind string, requestID string, err error) {
|
|
switch path {
|
|
case bidiAppendPath:
|
|
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
|
|
}
|
|
message, kind := unaryRequestMessage(path)
|
|
if message == nil {
|
|
return "", "", "", nil
|
|
}
|
|
if err := proto.Unmarshal(payload, message); err != nil {
|
|
return "", "", "", err
|
|
}
|
|
return marshalProtoJSON(message), kind, "", nil
|
|
}
|
|
|
|
func decodeUnaryResponse(path string, payload []byte) (decodedJSON string, kind string, err error) {
|
|
message, kind := unaryResponseMessage(path)
|
|
if message == nil {
|
|
return "", "", nil
|
|
}
|
|
if err := proto.Unmarshal(payload, message); err != nil {
|
|
return "", "", err
|
|
}
|
|
return marshalProtoJSON(message), kind, nil
|
|
}
|
|
|
|
func unaryRequestMessage(path string) (proto.Message, string) {
|
|
switch path {
|
|
case forkBackgroundComposerPath:
|
|
return &aiserverv1.ForkBackgroundComposerRequest{}, "fork_background_composer_request"
|
|
case notifyConversationClonePath:
|
|
return &agentv1.NotifyConversationCloneRequest{}, "notify_conversation_clone_request"
|
|
case uploadConversationBlobsPath:
|
|
return &agentv1.UploadConversationBlobsRequest{}, "upload_conversation_blobs_request"
|
|
default:
|
|
return nil, ""
|
|
}
|
|
}
|
|
|
|
func unaryResponseMessage(path string) (proto.Message, string) {
|
|
switch path {
|
|
case forkBackgroundComposerPath:
|
|
return &aiserverv1.ForkBackgroundComposerResponse{}, "fork_background_composer_response"
|
|
case notifyConversationClonePath:
|
|
return &agentv1.NotifyConversationCloneResponse{}, "notify_conversation_clone_response"
|
|
case uploadConversationBlobsPath:
|
|
return &agentv1.UploadConversationBlobsResponse{}, "upload_conversation_blobs_response"
|
|
default:
|
|
return nil, ""
|
|
}
|
|
}
|
|
|
|
func decodesUnaryRequest(path string) bool {
|
|
if path == bidiAppendPath {
|
|
return true
|
|
}
|
|
message, _ := unaryRequestMessage(path)
|
|
return message != nil
|
|
}
|
|
|
|
func decodesUnaryResponse(path string) bool {
|
|
message, _ := unaryResponseMessage(path)
|
|
return 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)
|
|
}
|