From 19c52bcb60d26a1eaa0db3184b041e3b069ea227 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=A7=9C=E6=81=92?= Date: Wed, 25 Mar 2026 10:14:14 +0800 Subject: [PATCH 01/13] feat: stash code --- cmd/mcpdebug/main.go | 20 + cmd/protocheck/main.go | 32 + cmd/server/main.go | 4 + internal/auth/cursor/oauth.go | 218 +++ internal/auth/cursor/proto/connect.go | 71 + internal/auth/cursor/proto/decode.go | 507 +++++++ internal/auth/cursor/proto/descriptor.go | 1244 ++++++++++++++++ internal/auth/cursor/proto/encode.go | 491 +++++++ internal/auth/cursor/proto/fieldnumbers.go | 332 +++++ internal/auth/cursor/proto/h2stream.go | 273 ++++ internal/cmd/auth_manager.go | 1 + internal/cmd/cursor_login.go | 38 + internal/registry/model_definitions.go | 15 + internal/runtime/executor/cursor_executor.go | 1341 ++++++++++++++++++ internal/util/provider.go | 6 + sdk/auth/cursor.go | 91 ++ sdk/auth/refresh_registry.go | 1 + sdk/cliproxy/auth/scheduler.go | 5 + sdk/cliproxy/service.go | 7 + test_cursor.sh | 309 ++++ 20 files changed, 5006 insertions(+) create mode 100644 cmd/mcpdebug/main.go create mode 100644 cmd/protocheck/main.go create mode 100644 internal/auth/cursor/oauth.go create mode 100644 internal/auth/cursor/proto/connect.go create mode 100644 internal/auth/cursor/proto/decode.go create mode 100644 internal/auth/cursor/proto/descriptor.go create mode 100644 internal/auth/cursor/proto/encode.go create mode 100644 internal/auth/cursor/proto/fieldnumbers.go create mode 100644 internal/auth/cursor/proto/h2stream.go create mode 100644 internal/cmd/cursor_login.go create mode 100644 internal/runtime/executor/cursor_executor.go create mode 100644 sdk/auth/cursor.go create mode 100755 test_cursor.sh diff --git a/cmd/mcpdebug/main.go b/cmd/mcpdebug/main.go new file mode 100644 index 00000000..51f1b64f --- /dev/null +++ b/cmd/mcpdebug/main.go @@ -0,0 +1,20 @@ +package main + +import ( + "encoding/hex" + "fmt" + "os" + + cursorproto "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/cursor/proto" +) + +func main() { + // Encode MCP result with empty execId + resultBytes := cursorproto.EncodeExecMcpResult(1, "", `{"test": "data"}`, false) + fmt.Printf("Result protobuf hex: %s\n", hex.EncodeToString(resultBytes)) + fmt.Printf("Result length: %d bytes\n", len(resultBytes)) + + // Write to file for analysis + os.WriteFile("mcp_result.bin", resultBytes) + fmt.Println("Wrote mcp_result.bin") +} diff --git a/cmd/protocheck/main.go b/cmd/protocheck/main.go new file mode 100644 index 00000000..af68bbc6 --- /dev/null +++ b/cmd/protocheck/main.go @@ -0,0 +1,32 @@ +package main + +import ( + "fmt" + cursorproto "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/cursor/proto" +) + +func main() { + ecm := cursorproto.NewMsg("ExecClientMessage") + + // Try different field names + names := []string{ + "mcp_result", "mcpResult", "McpResult", "MCP_RESULT", + "shell_result", "shellResult", + } + + for _, name := range names { + fd := ecm.Descriptor().Fields().ByName(name) + if fd != nil { + fmt.Printf("Found field %q: number=%d, kind=%s\n", name, fd.Number(), fd.Kind()) + } else { + fmt.Printf("Field %q NOT FOUND\n", name) + } + } + + // List all fields + fmt.Println("\nAll fields in ExecClientMessage:") + for i := 0; i < ecm.Descriptor().Fields().Len(); i++ { + f := ecm.Descriptor().Fields().Get(i) + fmt.Printf(" %d: %q (number=%d)\n", i, f.Name(), f.Number()) + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go index 9228d2a4..b6f5f47a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -85,6 +85,7 @@ func main() { var oauthCallbackPort int var antigravityLogin bool var kimiLogin bool + var cursorLogin bool var kiroLogin bool var kiroGoogleLogin bool var kiroAWSLogin bool @@ -123,6 +124,7 @@ func main() { flag.BoolVar(&noIncognito, "no-incognito", false, "Force disable incognito mode (uses existing browser session)") flag.BoolVar(&antigravityLogin, "antigravity-login", false, "Login to Antigravity using OAuth") flag.BoolVar(&kimiLogin, "kimi-login", false, "Login to Kimi using OAuth") + flag.BoolVar(&cursorLogin, "cursor-login", false, "Login to Cursor using OAuth") flag.BoolVar(&kiroLogin, "kiro-login", false, "Login to Kiro using Google OAuth") flag.BoolVar(&kiroGoogleLogin, "kiro-google-login", false, "Login to Kiro using Google OAuth (same as --kiro-login)") flag.BoolVar(&kiroAWSLogin, "kiro-aws-login", false, "Login to Kiro using AWS Builder ID (device code flow)") @@ -544,6 +546,8 @@ func main() { cmd.DoGitLabTokenLogin(cfg, options) } else if kimiLogin { cmd.DoKimiLogin(cfg, options) + } else if cursorLogin { + cmd.DoCursorLogin(cfg, options) } else if kiroLogin { // For Kiro auth, default to incognito mode for multi-account support // Users can explicitly override with --no-incognito diff --git a/internal/auth/cursor/oauth.go b/internal/auth/cursor/oauth.go new file mode 100644 index 00000000..065eff7e --- /dev/null +++ b/internal/auth/cursor/oauth.go @@ -0,0 +1,218 @@ +// Package cursor implements Cursor OAuth PKCE authentication and token refresh. +package cursor + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "math" + "net/http" + "strings" + "time" +) + +const ( + CursorLoginURL = "https://cursor.com/loginDeepControl" + CursorPollURL = "https://api2.cursor.sh/auth/poll" + CursorRefreshURL = "https://api2.cursor.sh/auth/exchange_user_api_key" + + pollMaxAttempts = 150 + pollBaseDelay = 1 * time.Second + pollMaxDelay = 10 * time.Second + pollBackoffMultiply = 1.2 + maxConsecutiveErrors = 10 +) + +// AuthParams holds the PKCE parameters for Cursor login. +type AuthParams struct { + Verifier string + Challenge string + UUID string + LoginURL string +} + +// TokenPair holds the access and refresh tokens from Cursor. +type TokenPair struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` +} + +// GeneratePKCE creates a PKCE verifier and challenge pair. +func GeneratePKCE() (verifier, challenge string, err error) { + verifierBytes := make([]byte, 96) + if _, err = rand.Read(verifierBytes); err != nil { + return "", "", fmt.Errorf("cursor: failed to generate PKCE verifier: %w", err) + } + verifier = base64.RawURLEncoding.EncodeToString(verifierBytes) + + h := sha256.Sum256([]byte(verifier)) + challenge = base64.RawURLEncoding.EncodeToString(h[:]) + return verifier, challenge, nil +} + +// GenerateAuthParams creates the full set of auth params for Cursor login. +func GenerateAuthParams() (*AuthParams, error) { + verifier, challenge, err := GeneratePKCE() + if err != nil { + return nil, err + } + + uuidBytes := make([]byte, 16) + if _, err = rand.Read(uuidBytes); err != nil { + return nil, fmt.Errorf("cursor: failed to generate UUID: %w", err) + } + uuid := fmt.Sprintf("%x-%x-%x-%x-%x", + uuidBytes[0:4], uuidBytes[4:6], uuidBytes[6:8], uuidBytes[8:10], uuidBytes[10:16]) + + loginURL := fmt.Sprintf("%s?challenge=%s&uuid=%s&mode=login&redirectTarget=cli", + CursorLoginURL, challenge, uuid) + + return &AuthParams{ + Verifier: verifier, + Challenge: challenge, + UUID: uuid, + LoginURL: loginURL, + }, nil +} + +// PollForAuth polls the Cursor auth endpoint until the user completes login. +func PollForAuth(ctx context.Context, uuid, verifier string) (*TokenPair, error) { + delay := pollBaseDelay + consecutiveErrors := 0 + + client := &http.Client{Timeout: 10 * time.Second} + + for attempt := 0; attempt < pollMaxAttempts; attempt++ { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(delay): + } + + url := fmt.Sprintf("%s?uuid=%s&verifier=%s", CursorPollURL, uuid, verifier) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("cursor: failed to create poll request: %w", err) + } + + resp, err := client.Do(req) + if err != nil { + consecutiveErrors++ + if consecutiveErrors >= maxConsecutiveErrors { + return nil, fmt.Errorf("cursor: too many consecutive poll errors (last: %v)", err) + } + delay = minDuration(time.Duration(float64(delay)*pollBackoffMultiply), pollMaxDelay) + continue + } + + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + // Still waiting for user to authorize + consecutiveErrors = 0 + delay = minDuration(time.Duration(float64(delay)*pollBackoffMultiply), pollMaxDelay) + continue + } + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + var tokens TokenPair + if err := json.Unmarshal(body, &tokens); err != nil { + return nil, fmt.Errorf("cursor: failed to parse auth response: %w", err) + } + return &tokens, nil + } + + return nil, fmt.Errorf("cursor: poll failed with status %d: %s", resp.StatusCode, string(body)) + } + + return nil, fmt.Errorf("cursor: authentication polling timeout (waited ~%.0f seconds)", + float64(pollMaxAttempts)*pollMaxDelay.Seconds()/2) +} + +// RefreshToken refreshes a Cursor access token using the refresh token. +func RefreshToken(ctx context.Context, refreshToken string) (*TokenPair, error) { + client := &http.Client{Timeout: 10 * time.Second} + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, CursorRefreshURL, + strings.NewReader("{}")) + if err != nil { + return nil, fmt.Errorf("cursor: failed to create refresh request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+refreshToken) + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("cursor: token refresh request failed: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("cursor: token refresh failed (status %d): %s", resp.StatusCode, string(body)) + } + + var tokens TokenPair + if err := json.Unmarshal(body, &tokens); err != nil { + return nil, fmt.Errorf("cursor: failed to parse refresh response: %w", err) + } + + // Keep original refresh token if not returned + if tokens.RefreshToken == "" { + tokens.RefreshToken = refreshToken + } + + return &tokens, nil +} + +// GetTokenExpiry extracts the JWT expiry from an access token with a 5-minute safety margin. +// Falls back to 1 hour from now if the token can't be parsed. +func GetTokenExpiry(token string) time.Time { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return time.Now().Add(1 * time.Hour) + } + + // Decode the payload (middle part) + payload := parts[1] + // Add padding if needed + switch len(payload) % 4 { + case 2: + payload += "==" + case 3: + payload += "=" + } + // Replace URL-safe characters + payload = strings.ReplaceAll(payload, "-", "+") + payload = strings.ReplaceAll(payload, "_", "/") + + decoded, err := base64.StdEncoding.DecodeString(payload) + if err != nil { + return time.Now().Add(1 * time.Hour) + } + + var claims struct { + Exp float64 `json:"exp"` + } + if err := json.Unmarshal(decoded, &claims); err != nil || claims.Exp == 0 { + return time.Now().Add(1 * time.Hour) + } + + sec, frac := math.Modf(claims.Exp) + expiry := time.Unix(int64(sec), int64(frac*1e9)) + // Subtract 5-minute safety margin + return expiry.Add(-5 * time.Minute) +} + +func minDuration(a, b time.Duration) time.Duration { + if a < b { + return a + } + return b +} diff --git a/internal/auth/cursor/proto/connect.go b/internal/auth/cursor/proto/connect.go new file mode 100644 index 00000000..db9e4288 --- /dev/null +++ b/internal/auth/cursor/proto/connect.go @@ -0,0 +1,71 @@ +package proto + +import ( + "encoding/binary" + "encoding/json" + "fmt" +) + +const ( + // ConnectEndStreamFlag marks the end-of-stream frame (trailers). + ConnectEndStreamFlag byte = 0x02 + // ConnectCompressionFlag indicates the payload is compressed (not supported). + ConnectCompressionFlag byte = 0x01 + // ConnectFrameHeaderSize is the fixed 5-byte frame header. + ConnectFrameHeaderSize = 5 +) + +// FrameConnectMessage wraps a protobuf payload in a Connect frame. +// Frame format: [1 byte flags][4 bytes payload length (big-endian)][payload] +func FrameConnectMessage(data []byte, flags byte) []byte { + frame := make([]byte, ConnectFrameHeaderSize+len(data)) + frame[0] = flags + binary.BigEndian.PutUint32(frame[1:5], uint32(len(data))) + copy(frame[5:], data) + return frame +} + +// ParseConnectFrame extracts one frame from a buffer. +// Returns (flags, payload, bytesConsumed, ok). +// ok is false when the buffer is too short for a complete frame. +func ParseConnectFrame(buf []byte) (flags byte, payload []byte, consumed int, ok bool) { + if len(buf) < ConnectFrameHeaderSize { + return 0, nil, 0, false + } + flags = buf[0] + length := binary.BigEndian.Uint32(buf[1:5]) + total := ConnectFrameHeaderSize + int(length) + if len(buf) < total { + return 0, nil, 0, false + } + return flags, buf[5:total], total, true +} + +// ParseConnectEndStream parses a Connect end-of-stream frame payload (JSON). +// Returns nil if there is no error in the trailer. +func ParseConnectEndStream(data []byte) error { + if len(data) == 0 { + return nil + } + var trailer struct { + Error *struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(data, &trailer); err != nil { + return fmt.Errorf("failed to parse Connect end stream: %w", err) + } + if trailer.Error != nil { + code := trailer.Error.Code + if code == "" { + code = "unknown" + } + msg := trailer.Error.Message + if msg == "" { + msg = "Unknown error" + } + return fmt.Errorf("Connect error %s: %s", code, msg) + } + return nil +} diff --git a/internal/auth/cursor/proto/decode.go b/internal/auth/cursor/proto/decode.go new file mode 100644 index 00000000..cc10d483 --- /dev/null +++ b/internal/auth/cursor/proto/decode.go @@ -0,0 +1,507 @@ +package proto + +import ( + "encoding/hex" + "fmt" + + log "github.com/sirupsen/logrus" + "google.golang.org/protobuf/encoding/protowire" +) + +// ServerMessageType identifies the kind of decoded server message. +type ServerMessageType int + +const ( + ServerMsgUnknown ServerMessageType = iota + ServerMsgTextDelta // Text content delta + ServerMsgThinkingDelta // Thinking/reasoning delta + ServerMsgThinkingCompleted // Thinking completed + ServerMsgKvGetBlob // Server wants a blob + ServerMsgKvSetBlob // Server wants to store a blob + ServerMsgExecRequestCtx // Server requests context (tools, etc.) + ServerMsgExecMcpArgs // Server wants MCP tool execution + ServerMsgExecShellArgs // Rejected: shell command + ServerMsgExecReadArgs // Rejected: file read + ServerMsgExecWriteArgs // Rejected: file write + ServerMsgExecDeleteArgs // Rejected: file delete + ServerMsgExecLsArgs // Rejected: directory listing + ServerMsgExecGrepArgs // Rejected: grep search + ServerMsgExecFetchArgs // Rejected: HTTP fetch + ServerMsgExecDiagnostics // Respond with empty diagnostics + ServerMsgExecShellStream // Rejected: shell stream + ServerMsgExecBgShellSpawn // Rejected: background shell + ServerMsgExecWriteShellStdin // Rejected: write shell stdin + ServerMsgExecOther // Other exec types (respond with empty) +) + +// DecodedServerMessage holds parsed data from an AgentServerMessage. +type DecodedServerMessage struct { + Type ServerMessageType + + // For text/thinking deltas + Text string + + // For KV messages + KvId uint32 + BlobId []byte // hex-encoded blob ID + BlobData []byte // for setBlobArgs + + // For exec messages + ExecMsgId uint32 + ExecId string + + // For MCP args + McpToolName string + McpToolCallId string + McpArgs map[string][]byte // arg name -> protobuf-encoded value + + // For rejection context + Path string + Command string + WorkingDirectory string + Url string + + // For other exec - the raw field number for building a response + ExecFieldNumber int +} + +// DecodeAgentServerMessage parses an AgentServerMessage and returns +// a structured representation of the first meaningful message found. +func DecodeAgentServerMessage(data []byte) (*DecodedServerMessage, error) { + msg := &DecodedServerMessage{Type: ServerMsgUnknown} + + for len(data) > 0 { + num, typ, n := protowire.ConsumeTag(data) + if n < 0 { + return msg, fmt.Errorf("invalid tag") + } + data = data[n:] + + switch typ { + case protowire.BytesType: + val, n := protowire.ConsumeBytes(data) + if n < 0 { + return msg, fmt.Errorf("invalid bytes field %d", num) + } + data = data[n:] + + // Debug: log top-level ASM fields + log.Debugf("DecodeAgentServerMessage: found ASM field %d, len=%d", num, len(val)) + + switch num { + case ASM_InteractionUpdate: + log.Debugf("DecodeAgentServerMessage: calling decodeInteractionUpdate") + decodeInteractionUpdate(val, msg) + case ASM_ExecServerMessage: + log.Debugf("DecodeAgentServerMessage: calling decodeExecServerMessage") + decodeExecServerMessage(val, msg) + case ASM_KvServerMessage: + decodeKvServerMessage(val, msg) + case ASM_ConversationCheckpoint: + // Ignore checkpoint updates + log.Debugf("DecodeAgentServerMessage: ignoring ConversationCheckpoint") + } + + case protowire.VarintType: + _, n := protowire.ConsumeVarint(data) + if n < 0 { + return msg, fmt.Errorf("invalid varint field %d", num) + } + data = data[n:] + + default: + // Skip unknown wire types + n := protowire.ConsumeFieldValue(num, typ, data) + if n < 0 { + return msg, fmt.Errorf("invalid field %d", num) + } + data = data[n:] + } + } + + return msg, nil +} + +func decodeInteractionUpdate(data []byte, msg *DecodedServerMessage) { + log.Debugf("decodeInteractionUpdate: input len=%d, hex=%x", len(data), data) + for len(data) > 0 { + num, typ, n := protowire.ConsumeTag(data) + if n < 0 { + log.Debugf("decodeInteractionUpdate: invalid tag, remaining=%x", data) + return + } + data = data[n:] + log.Debugf("decodeInteractionUpdate: field=%d wire=%d remaining=%d bytes", num, typ, len(data)) + + if typ == protowire.BytesType { + val, n := protowire.ConsumeBytes(data) + if n < 0 { + log.Debugf("decodeInteractionUpdate: invalid bytes field %d", num) + return + } + data = data[n:] + log.Debugf("decodeInteractionUpdate: field %d content len=%d, first 20 bytes: %x", num, len(val), val[:min(20, len(val))]) + + switch num { + case IU_TextDelta: + msg.Type = ServerMsgTextDelta + msg.Text = decodeStringField(val, TDU_Text) + log.Debugf("decodeInteractionUpdate: TextDelta text=%q", msg.Text) + case IU_ThinkingDelta: + msg.Type = ServerMsgThinkingDelta + msg.Text = decodeStringField(val, TKD_Text) + log.Debugf("decodeInteractionUpdate: ThinkingDelta text=%q", msg.Text) + case IU_ThinkingCompleted: + msg.Type = ServerMsgThinkingCompleted + log.Debugf("decodeInteractionUpdate: ThinkingCompleted") + case 2: + // tool_call_started - ignore but log + log.Debugf("decodeInteractionUpdate: ToolCallStarted (ignored)") + case 3: + // tool_call_completed - ignore but log + log.Debugf("decodeInteractionUpdate: ToolCallCompleted (ignored)") + default: + log.Debugf("decodeInteractionUpdate: unknown field %d", num) + } + } else { + n := protowire.ConsumeFieldValue(num, typ, data) + if n < 0 { + return + } + data = data[n:] + } + } +} + +func decodeKvServerMessage(data []byte, msg *DecodedServerMessage) { + for len(data) > 0 { + num, typ, n := protowire.ConsumeTag(data) + if n < 0 { + return + } + data = data[n:] + + switch typ { + case protowire.VarintType: + val, n := protowire.ConsumeVarint(data) + if n < 0 { + return + } + data = data[n:] + if num == KSM_Id { + msg.KvId = uint32(val) + } + + case protowire.BytesType: + val, n := protowire.ConsumeBytes(data) + if n < 0 { + return + } + data = data[n:] + + switch num { + case KSM_GetBlobArgs: + msg.Type = ServerMsgKvGetBlob + msg.BlobId = decodeBytesField(val, GBA_BlobId) + case KSM_SetBlobArgs: + msg.Type = ServerMsgKvSetBlob + decodeSetBlobArgs(val, msg) + } + + default: + n := protowire.ConsumeFieldValue(num, typ, data) + if n < 0 { + return + } + data = data[n:] + } + } +} + +func decodeSetBlobArgs(data []byte, msg *DecodedServerMessage) { + for len(data) > 0 { + num, typ, n := protowire.ConsumeTag(data) + if n < 0 { + return + } + data = data[n:] + + if typ == protowire.BytesType { + val, n := protowire.ConsumeBytes(data) + if n < 0 { + return + } + data = data[n:] + switch num { + case SBA_BlobId: + msg.BlobId = val + case SBA_BlobData: + msg.BlobData = val + } + } else { + n := protowire.ConsumeFieldValue(num, typ, data) + if n < 0 { + return + } + data = data[n:] + } + } +} + +func decodeExecServerMessage(data []byte, msg *DecodedServerMessage) { + for len(data) > 0 { + num, typ, n := protowire.ConsumeTag(data) + if n < 0 { + return + } + data = data[n:] + + switch typ { + case protowire.VarintType: + val, n := protowire.ConsumeVarint(data) + if n < 0 { + return + } + data = data[n:] + if num == ESM_Id { + msg.ExecMsgId = uint32(val) + log.Debugf("decodeExecServerMessage: ESM_Id = %d", val) + } + + case protowire.BytesType: + val, n := protowire.ConsumeBytes(data) + if n < 0 { + return + } + data = data[n:] + + // Debug: log all fields found in ExecServerMessage + log.Debugf("decodeExecServerMessage: found field %d, len=%d, first 20 bytes: %x", num, len(val), val[:min(20, len(val))]) + + switch num { + case ESM_ExecId: + msg.ExecId = string(val) + log.Debugf("decodeExecServerMessage: ESM_ExecId = %q", msg.ExecId) + case ESM_RequestContextArgs: + msg.Type = ServerMsgExecRequestCtx + case ESM_McpArgs: + msg.Type = ServerMsgExecMcpArgs + decodeMcpArgs(val, msg) + case ESM_ShellArgs: + msg.Type = ServerMsgExecShellArgs + decodeShellArgs(val, msg) + case ESM_ShellStreamArgs: + msg.Type = ServerMsgExecShellStream + decodeShellArgs(val, msg) + case ESM_ReadArgs: + msg.Type = ServerMsgExecReadArgs + msg.Path = decodeStringField(val, RA_Path) + case ESM_WriteArgs: + msg.Type = ServerMsgExecWriteArgs + msg.Path = decodeStringField(val, WA_Path) + case ESM_DeleteArgs: + msg.Type = ServerMsgExecDeleteArgs + msg.Path = decodeStringField(val, DA_Path) + case ESM_LsArgs: + msg.Type = ServerMsgExecLsArgs + msg.Path = decodeStringField(val, LA_Path) + case ESM_GrepArgs: + msg.Type = ServerMsgExecGrepArgs + case ESM_FetchArgs: + msg.Type = ServerMsgExecFetchArgs + msg.Url = decodeStringField(val, FA_Url) + case ESM_DiagnosticsArgs: + msg.Type = ServerMsgExecDiagnostics + case ESM_BackgroundShellSpawn: + msg.Type = ServerMsgExecBgShellSpawn + decodeShellArgs(val, msg) // same structure + case ESM_WriteShellStdinArgs: + msg.Type = ServerMsgExecWriteShellStdin + default: + // Unknown exec types - only set if we haven't identified the type yet + // (other fields like span_context (19) come after the exec type field) + if msg.Type == ServerMsgUnknown { + msg.Type = ServerMsgExecOther + msg.ExecFieldNumber = int(num) + } + } + + default: + n := protowire.ConsumeFieldValue(num, typ, data) + if n < 0 { + return + } + data = data[n:] + } + } +} + +func decodeMcpArgs(data []byte, msg *DecodedServerMessage) { + msg.McpArgs = make(map[string][]byte) + for len(data) > 0 { + num, typ, n := protowire.ConsumeTag(data) + if n < 0 { + return + } + data = data[n:] + + if typ == protowire.BytesType { + val, n := protowire.ConsumeBytes(data) + if n < 0 { + return + } + data = data[n:] + + switch num { + case MCA_Name: + msg.McpToolName = string(val) + case MCA_Args: + // Map entries are encoded as submessages with key=1, value=2 + decodeMapEntry(val, msg.McpArgs) + case MCA_ToolCallId: + msg.McpToolCallId = string(val) + case MCA_ToolName: + // ToolName takes precedence if present + if msg.McpToolName == "" || string(val) != "" { + msg.McpToolName = string(val) + } + } + } else { + n := protowire.ConsumeFieldValue(num, typ, data) + if n < 0 { + return + } + data = data[n:] + } + } +} + +func decodeMapEntry(data []byte, m map[string][]byte) { + var key string + var value []byte + for len(data) > 0 { + num, typ, n := protowire.ConsumeTag(data) + if n < 0 { + return + } + data = data[n:] + + if typ == protowire.BytesType { + val, n := protowire.ConsumeBytes(data) + if n < 0 { + return + } + data = data[n:] + if num == 1 { + key = string(val) + } else if num == 2 { + value = append([]byte(nil), val...) + } + } else { + n := protowire.ConsumeFieldValue(num, typ, data) + if n < 0 { + return + } + data = data[n:] + } + } + if key != "" { + m[key] = value + } +} + +func decodeShellArgs(data []byte, msg *DecodedServerMessage) { + for len(data) > 0 { + num, typ, n := protowire.ConsumeTag(data) + if n < 0 { + return + } + data = data[n:] + + if typ == protowire.BytesType { + val, n := protowire.ConsumeBytes(data) + if n < 0 { + return + } + data = data[n:] + switch num { + case SHA_Command: + msg.Command = string(val) + case SHA_WorkingDirectory: + msg.WorkingDirectory = string(val) + } + } else { + n := protowire.ConsumeFieldValue(num, typ, data) + if n < 0 { + return + } + data = data[n:] + } + } +} + +// --- Helper decoders --- + +// decodeStringField extracts a string from the first matching field in a submessage. +func decodeStringField(data []byte, targetField protowire.Number) string { + for len(data) > 0 { + num, typ, n := protowire.ConsumeTag(data) + if n < 0 { + return "" + } + data = data[n:] + + if typ == protowire.BytesType { + val, n := protowire.ConsumeBytes(data) + if n < 0 { + return "" + } + data = data[n:] + if num == targetField { + return string(val) + } + } else { + n := protowire.ConsumeFieldValue(num, typ, data) + if n < 0 { + return "" + } + data = data[n:] + } + } + return "" +} + +// decodeBytesField extracts bytes from the first matching field in a submessage. +func decodeBytesField(data []byte, targetField protowire.Number) []byte { + for len(data) > 0 { + num, typ, n := protowire.ConsumeTag(data) + if n < 0 { + return nil + } + data = data[n:] + + if typ == protowire.BytesType { + val, n := protowire.ConsumeBytes(data) + if n < 0 { + return nil + } + data = data[n:] + if num == targetField { + return append([]byte(nil), val...) + } + } else { + n := protowire.ConsumeFieldValue(num, typ, data) + if n < 0 { + return nil + } + data = data[n:] + } + } + return nil +} + +// BlobIdHex returns the hex string of a blob ID for use as a map key. +func BlobIdHex(blobId []byte) string { + return hex.EncodeToString(blobId) +} + diff --git a/internal/auth/cursor/proto/descriptor.go b/internal/auth/cursor/proto/descriptor.go new file mode 100644 index 00000000..a24b3fa9 --- /dev/null +++ b/internal/auth/cursor/proto/descriptor.go @@ -0,0 +1,1244 @@ +package proto + +import ( + "encoding/base64" + "sync" + + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protodesc" + "google.golang.org/protobuf/reflect/protoreflect" + descrptorpb "google.golang.org/protobuf/types/descriptorpb" +) + +// agentDescriptorB64 is the base64-encoded FileDescriptorProto for agent.proto. +// Extracted from alma-plugins/plugins/cursor-auth/proto/agent_pb.ts. +const agentDescriptorB64 = "" + + "CgthZ2VudC5wcm90bxIIYWdlbnQudjEicgoOR2xvYlRvb2xSZXN1bHQSLAoHc3VjY2VzcxgBIAEo" + + "CzIZLmFnZW50LnYxLkdsb2JUb29sU3VjY2Vzc0gAEigKBWVycm9yGAIgASgLMhcuYWdlbnQudjEu" + + "R2xvYlRvb2xFcnJvckgAQggKBnJlc3VsdCIeCg1HbG9iVG9vbEVycm9yEg0KBWVycm9yGAEgASgJ" + + "IokBCg9HbG9iVG9vbFN1Y2Nlc3MSDwoHcGF0dGVybhgBIAEoCRIMCgRwYXRoGAIgASgJEg0KBWZp" + + "bGVzGAMgAygJEhMKC3RvdGFsX2ZpbGVzGAQgASgFEhgKEGNsaWVudF90cnVuY2F0ZWQYBSABKAgS" + + "GQoRcmlwZ3JlcF90cnVuY2F0ZWQYBiABKAgiRgoMR2xvYlRvb2xDYWxsEgwKBGFyZ3MYASABKAwS" + + "KAoGcmVzdWx0GAIgASgLMhguYWdlbnQudjEuR2xvYlRvb2xSZXN1bHQibQoRUmVhZExpbnRzVG9v" + + "bENhbGwSKQoEYXJncxgBIAEoCzIbLmFnZW50LnYxLlJlYWRMaW50c1Rvb2xBcmdzEi0KBnJlc3Vs" + + "dBgCIAEoCzIdLmFnZW50LnYxLlJlYWRMaW50c1Rvb2xSZXN1bHQiIgoRUmVhZExpbnRzVG9vbEFy" + + "Z3MSDQoFcGF0aHMYASADKAkigQEKE1JlYWRMaW50c1Rvb2xSZXN1bHQSMQoHc3VjY2VzcxgBIAEo" + + "CzIeLmFnZW50LnYxLlJlYWRMaW50c1Rvb2xTdWNjZXNzSAASLQoFZXJyb3IYAiABKAsyHC5hZ2Vu" + + "dC52MS5SZWFkTGludHNUb29sRXJyb3JIAEIICgZyZXN1bHQiewoUUmVhZExpbnRzVG9vbFN1Y2Nl" + + "c3MSMwoQZmlsZV9kaWFnbm9zdGljcxgBIAMoCzIZLmFnZW50LnYxLkZpbGVEaWFnbm9zdGljcxIT" + + "Cgt0b3RhbF9maWxlcxgCIAEoBRIZChF0b3RhbF9kaWFnbm9zdGljcxgDIAEoBSJpCg9GaWxlRGlh" + + "Z25vc3RpY3MSDAoEcGF0aBgBIAEoCRItCgtkaWFnbm9zdGljcxgCIAMoCzIYLmFnZW50LnYxLkRp" + + "YWdub3N0aWNJdGVtEhkKEWRpYWdub3N0aWNzX2NvdW50GAMgASgFIqsBCg5EaWFnbm9zdGljSXRl" + + "bRIuCghzZXZlcml0eRgBIAEoDjIcLmFnZW50LnYxLkRpYWdub3N0aWNTZXZlcml0eRIoCgVyYW5n" + + "ZRgCIAEoCzIZLmFnZW50LnYxLkRpYWdub3N0aWNSYW5nZRIPCgdtZXNzYWdlGAMgASgJEg4KBnNv" + + "dXJjZRgEIAEoCRIMCgRjb2RlGAUgASgJEhAKCGlzX3N0YWxlGAYgASgIIlUKD0RpYWdub3N0aWNS" + + "YW5nZRIhCgVzdGFydBgBIAEoCzISLmFnZW50LnYxLlBvc2l0aW9uEh8KA2VuZBgCIAEoCzISLmFn" + + "ZW50LnYxLlBvc2l0aW9uIisKElJlYWRMaW50c1Rvb2xFcnJvchIVCg1lcnJvcl9tZXNzYWdlGAEg" + + "ASgJIh0KDE1jcFRvb2xFcnJvchINCgVlcnJvchgBIAEoCSLSAQoNTWNwVG9vbFJlc3VsdBInCgdz" + + "dWNjZXNzGAEgASgLMhQuYWdlbnQudjEuTWNwU3VjY2Vzc0gAEicKBWVycm9yGAIgASgLMhYuYWdl" + + "bnQudjEuTWNwVG9vbEVycm9ySAASKQoIcmVqZWN0ZWQYAyABKAsyFS5hZ2VudC52MS5NY3BSZWpl" + + "Y3RlZEgAEjoKEXBlcm1pc3Npb25fZGVuaWVkGAQgASgLMh0uYWdlbnQudjEuTWNwUGVybWlzc2lv" + + "bkRlbmllZEgAQggKBnJlc3VsdCJXCgtNY3BUb29sQ2FsbBIfCgRhcmdzGAEgASgLMhEuYWdlbnQu" + + "djEuTWNwQXJncxInCgZyZXN1bHQYAiABKAsyFy5hZ2VudC52MS5NY3BUb29sUmVzdWx0Im0KEVNl" + + "bVNlYXJjaFRvb2xDYWxsEikKBGFyZ3MYASABKAsyGy5hZ2VudC52MS5TZW1TZWFyY2hUb29sQXJn" + + "cxItCgZyZXN1bHQYAiABKAsyHS5hZ2VudC52MS5TZW1TZWFyY2hUb29sUmVzdWx0IlMKEVNlbVNl" + + "YXJjaFRvb2xBcmdzEg0KBXF1ZXJ5GAEgASgJEhoKEnRhcmdldF9kaXJlY3RvcmllcxgCIAMoCRIT" + + "CgtleHBsYW5hdGlvbhgDIAEoCSKBAQoTU2VtU2VhcmNoVG9vbFJlc3VsdBIxCgdzdWNjZXNzGAEg" + + "ASgLMh4uYWdlbnQudjEuU2VtU2VhcmNoVG9vbFN1Y2Nlc3NIABItCgVlcnJvchgCIAEoCzIcLmFn" + + "ZW50LnYxLlNlbVNlYXJjaFRvb2xFcnJvckgAQggKBnJlc3VsdCI9ChRTZW1TZWFyY2hUb29sU3Vj" + + "Y2VzcxIPCgdyZXN1bHRzGAEgASgJEhQKDGNvZGVfcmVzdWx0cxgCIAMoDCIrChJTZW1TZWFyY2hU" + + "b29sRXJyb3ISFQoNZXJyb3JfbWVzc2FnZRgBIAEoCSKCAQoYTGlzdE1jcFJlc291cmNlc1Rvb2xD" + + "YWxsEjAKBGFyZ3MYASABKAsyIi5hZ2VudC52MS5MaXN0TWNwUmVzb3VyY2VzRXhlY0FyZ3MSNAoG" + + "cmVzdWx0GAIgASgLMiQuYWdlbnQudjEuTGlzdE1jcFJlc291cmNlc0V4ZWNSZXN1bHQifwoXUmVh" + + "ZE1jcFJlc291cmNlVG9vbENhbGwSLwoEYXJncxgBIAEoCzIhLmFnZW50LnYxLlJlYWRNY3BSZXNv" + + "dXJjZUV4ZWNBcmdzEjMKBnJlc3VsdBgCIAEoCzIjLmFnZW50LnYxLlJlYWRNY3BSZXNvdXJjZUV4" + + "ZWNSZXN1bHQiWQoNRmV0Y2hUb29sQ2FsbBIhCgRhcmdzGAEgASgLMhMuYWdlbnQudjEuRmV0Y2hB" + + "cmdzEiUKBnJlc3VsdBgCIAEoCzIVLmFnZW50LnYxLkZldGNoUmVzdWx0Im4KFFJlY29yZFNjcmVl" + + "blRvb2xDYWxsEigKBGFyZ3MYASABKAsyGi5hZ2VudC52MS5SZWNvcmRTY3JlZW5BcmdzEiwKBnJl" + + "c3VsdBgCIAEoCzIcLmFnZW50LnYxLlJlY29yZFNjcmVlblJlc3VsdCJ3ChdXcml0ZVNoZWxsU3Rk" + + "aW5Ub29sQ2FsbBIrCgRhcmdzGAEgASgLMh0uYWdlbnQudjEuV3JpdGVTaGVsbFN0ZGluQXJncxIv" + + "CgZyZXN1bHQYAiABKAsyHy5hZ2VudC52MS5Xcml0ZVNoZWxsU3RkaW5SZXN1bHQisQEKC1JlZmxl" + + "Y3RBcmdzEiIKGnVuZXhwZWN0ZWRfYWN0aW9uX291dGNvbWVzGAEgASgJEh0KFXJlbGV2YW50X2lu" + + "c3RydWN0aW9ucxgCIAEoCRIZChFzY2VuYXJpb19hbmFseXNpcxgDIAEoCRIaChJjcml0aWNhbF9z" + + "eW50aGVzaXMYBCABKAkSEgoKbmV4dF9zdGVwcxgFIAEoCRIUCgx0b29sX2NhbGxfaWQYBiABKAki" + + "bwoNUmVmbGVjdFJlc3VsdBIrCgdzdWNjZXNzGAEgASgLMhguYWdlbnQudjEuUmVmbGVjdFN1Y2Nl" + + "c3NIABInCgVlcnJvchgCIAEoCzIWLmFnZW50LnYxLlJlZmxlY3RFcnJvckgAQggKBnJlc3VsdCIQ" + + "Cg5SZWZsZWN0U3VjY2VzcyIdCgxSZWZsZWN0RXJyb3ISDQoFZXJyb3IYASABKAkiXwoPUmVmbGVj" + + "dFRvb2xDYWxsEiMKBGFyZ3MYASABKAsyFS5hZ2VudC52MS5SZWZsZWN0QXJncxInCgZyZXN1bHQY" + + "AiABKAsyFy5hZ2VudC52MS5SZWZsZWN0UmVzdWx0IlkKF1N0YXJ0R3JpbmRFeGVjdXRpb25Bcmdz" + + "EhgKC2V4cGxhbmF0aW9uGAEgASgJSACIAQESFAoMdG9vbF9jYWxsX2lkGAIgASgJQg4KDF9leHBs" + + "YW5hdGlvbiKTAQoZU3RhcnRHcmluZEV4ZWN1dGlvblJlc3VsdBI3CgdzdWNjZXNzGAEgASgLMiQu" + + "YWdlbnQudjEuU3RhcnRHcmluZEV4ZWN1dGlvblN1Y2Nlc3NIABIzCgVlcnJvchgCIAEoCzIiLmFn" + + "ZW50LnYxLlN0YXJ0R3JpbmRFeGVjdXRpb25FcnJvckgAQggKBnJlc3VsdCIcChpTdGFydEdyaW5k" + + "RXhlY3V0aW9uU3VjY2VzcyIpChhTdGFydEdyaW5kRXhlY3V0aW9uRXJyb3ISDQoFZXJyb3IYASAB" + + "KAkigwEKG1N0YXJ0R3JpbmRFeGVjdXRpb25Ub29sQ2FsbBIvCgRhcmdzGAEgASgLMiEuYWdlbnQu" + + "djEuU3RhcnRHcmluZEV4ZWN1dGlvbkFyZ3MSMwoGcmVzdWx0GAIgASgLMiMuYWdlbnQudjEuU3Rh" + + "cnRHcmluZEV4ZWN1dGlvblJlc3VsdCJYChZTdGFydEdyaW5kUGxhbm5pbmdBcmdzEhgKC2V4cGxh" + + "bmF0aW9uGAEgASgJSACIAQESFAoMdG9vbF9jYWxsX2lkGAIgASgJQg4KDF9leHBsYW5hdGlvbiKQ" + + "AQoYU3RhcnRHcmluZFBsYW5uaW5nUmVzdWx0EjYKB3N1Y2Nlc3MYASABKAsyIy5hZ2VudC52MS5T" + + "dGFydEdyaW5kUGxhbm5pbmdTdWNjZXNzSAASMgoFZXJyb3IYAiABKAsyIS5hZ2VudC52MS5TdGFy" + + "dEdyaW5kUGxhbm5pbmdFcnJvckgAQggKBnJlc3VsdCIbChlTdGFydEdyaW5kUGxhbm5pbmdTdWNj" + + "ZXNzIigKF1N0YXJ0R3JpbmRQbGFubmluZ0Vycm9yEg0KBWVycm9yGAEgASgJIoABChpTdGFydEdy" + + "aW5kUGxhbm5pbmdUb29sQ2FsbBIuCgRhcmdzGAEgASgLMiAuYWdlbnQudjEuU3RhcnRHcmluZFBs" + + "YW5uaW5nQXJncxIyCgZyZXN1bHQYAiABKAsyIi5hZ2VudC52MS5TdGFydEdyaW5kUGxhbm5pbmdS" + + "ZXN1bHQinAEKCFRhc2tBcmdzEhMKC2Rlc2NyaXB0aW9uGAEgASgJEg4KBnByb21wdBgCIAEoCRIt" + + "Cg1zdWJhZ2VudF90eXBlGAMgASgLMhYuYWdlbnQudjEuU3ViYWdlbnRUeXBlEhIKBW1vZGVsGAQg" + + "ASgJSACIAQESEwoGcmVzdW1lGAUgASgJSAGIAQFCCAoGX21vZGVsQgkKB19yZXN1bWUiqgEKC1Rh" + + "c2tTdWNjZXNzEjYKEmNvbnZlcnNhdGlvbl9zdGVwcxgBIAMoCzIaLmFnZW50LnYxLkNvbnZlcnNh" + + "dGlvblN0ZXASFQoIYWdlbnRfaWQYAiABKAlIAIgBARIVCg1pc19iYWNrZ3JvdW5kGAMgASgIEhgK" + + "C2R1cmF0aW9uX21zGAQgASgESAGIAQFCCwoJX2FnZW50X2lkQg4KDF9kdXJhdGlvbl9tcyIaCglU" + + "YXNrRXJyb3ISDQoFZXJyb3IYASABKAkiZgoKVGFza1Jlc3VsdBIoCgdzdWNjZXNzGAEgASgLMhUu" + + "YWdlbnQudjEuVGFza1N1Y2Nlc3NIABIkCgVlcnJvchgCIAEoCzITLmFnZW50LnYxLlRhc2tFcnJv" + + "ckgAQggKBnJlc3VsdCJWCgxUYXNrVG9vbENhbGwSIAoEYXJncxgBIAEoCzISLmFnZW50LnYxLlRh" + + "c2tBcmdzEiQKBnJlc3VsdBgCIAEoCzIULmFnZW50LnYxLlRhc2tSZXN1bHQiTAoRVGFza1Rvb2xD" + + "YWxsRGVsdGESNwoSaW50ZXJhY3Rpb25fdXBkYXRlGAEgASgLMhsuYWdlbnQudjEuSW50ZXJhY3Rp" + + "b25VcGRhdGUiyw8KCFRvb2xDYWxsEjIKD3NoZWxsX3Rvb2xfY2FsbBgBIAEoCzIXLmFnZW50LnYx" + + "LlNoZWxsVG9vbENhbGxIABI0ChBkZWxldGVfdG9vbF9jYWxsGAMgASgLMhguYWdlbnQudjEuRGVs" + + "ZXRlVG9vbENhbGxIABIwCg5nbG9iX3Rvb2xfY2FsbBgEIAEoCzIWLmFnZW50LnYxLkdsb2JUb29s" + + "Q2FsbEgAEjAKDmdyZXBfdG9vbF9jYWxsGAUgASgLMhYuYWdlbnQudjEuR3JlcFRvb2xDYWxsSAAS" + + "MAoOcmVhZF90b29sX2NhbGwYCCABKAsyFi5hZ2VudC52MS5SZWFkVG9vbENhbGxIABI/ChZ1cGRh" + + "dGVfdG9kb3NfdG9vbF9jYWxsGAkgASgLMh0uYWdlbnQudjEuVXBkYXRlVG9kb3NUb29sQ2FsbEgA" + + "EjsKFHJlYWRfdG9kb3NfdG9vbF9jYWxsGAogASgLMhsuYWdlbnQudjEuUmVhZFRvZG9zVG9vbENh" + + "bGxIABIwCg5lZGl0X3Rvb2xfY2FsbBgMIAEoCzIWLmFnZW50LnYxLkVkaXRUb29sQ2FsbEgAEiwK" + + "DGxzX3Rvb2xfY2FsbBgNIAEoCzIULmFnZW50LnYxLkxzVG9vbENhbGxIABI7ChRyZWFkX2xpbnRz" + + "X3Rvb2xfY2FsbBgOIAEoCzIbLmFnZW50LnYxLlJlYWRMaW50c1Rvb2xDYWxsSAASLgoNbWNwX3Rv" + + "b2xfY2FsbBgPIAEoCzIVLmFnZW50LnYxLk1jcFRvb2xDYWxsSAASOwoUc2VtX3NlYXJjaF90b29s" + + "X2NhbGwYECABKAsyGy5hZ2VudC52MS5TZW1TZWFyY2hUb29sQ2FsbEgAEj0KFWNyZWF0ZV9wbGFu" + + "X3Rvb2xfY2FsbBgRIAEoCzIcLmFnZW50LnYxLkNyZWF0ZVBsYW5Ub29sQ2FsbEgAEjsKFHdlYl9z" + + "ZWFyY2hfdG9vbF9jYWxsGBIgASgLMhsuYWdlbnQudjEuV2ViU2VhcmNoVG9vbENhbGxIABIwCg50" + + "YXNrX3Rvb2xfY2FsbBgTIAEoCzIWLmFnZW50LnYxLlRhc2tUb29sQ2FsbEgAEkoKHGxpc3RfbWNw" + + "X3Jlc291cmNlc190b29sX2NhbGwYFCABKAsyIi5hZ2VudC52MS5MaXN0TWNwUmVzb3VyY2VzVG9v" + + "bENhbGxIABJIChtyZWFkX21jcF9yZXNvdXJjZV90b29sX2NhbGwYFSABKAsyIS5hZ2VudC52MS5S" + + "ZWFkTWNwUmVzb3VyY2VUb29sQ2FsbEgAEkYKGmFwcGx5X2FnZW50X2RpZmZfdG9vbF9jYWxsGBYg" + + "ASgLMiAuYWdlbnQudjEuQXBwbHlBZ2VudERpZmZUb29sQ2FsbEgAEj8KFmFza19xdWVzdGlvbl90" + + "b29sX2NhbGwYFyABKAsyHS5hZ2VudC52MS5Bc2tRdWVzdGlvblRvb2xDYWxsSAASMgoPZmV0Y2hf" + + "dG9vbF9jYWxsGBggASgLMhcuYWdlbnQudjEuRmV0Y2hUb29sQ2FsbEgAEj0KFXN3aXRjaF9tb2Rl" + + "X3Rvb2xfY2FsbBgZIAEoCzIcLmFnZW50LnYxLlN3aXRjaE1vZGVUb29sQ2FsbEgAEjsKFGV4YV9z" + + "ZWFyY2hfdG9vbF9jYWxsGBogASgLMhsuYWdlbnQudjEuRXhhU2VhcmNoVG9vbENhbGxIABI5ChNl" + + "eGFfZmV0Y2hfdG9vbF9jYWxsGBsgASgLMhouYWdlbnQudjEuRXhhRmV0Y2hUb29sQ2FsbEgAEkMK" + + "GGdlbmVyYXRlX2ltYWdlX3Rvb2xfY2FsbBgcIAEoCzIfLmFnZW50LnYxLkdlbmVyYXRlSW1hZ2VU" + + "b29sQ2FsbEgAEkEKF3JlY29yZF9zY3JlZW5fdG9vbF9jYWxsGB0gASgLMh4uYWdlbnQudjEuUmVj" + + "b3JkU2NyZWVuVG9vbENhbGxIABI/ChZjb21wdXRlcl91c2VfdG9vbF9jYWxsGB4gASgLMh0uYWdl" + + "bnQudjEuQ29tcHV0ZXJVc2VUb29sQ2FsbEgAEkgKG3dyaXRlX3NoZWxsX3N0ZGluX3Rvb2xfY2Fs" + + "bBgfIAEoCzIhLmFnZW50LnYxLldyaXRlU2hlbGxTdGRpblRvb2xDYWxsSAASNgoRcmVmbGVjdF90" + + "b29sX2NhbGwYICABKAsyGS5hZ2VudC52MS5SZWZsZWN0VG9vbENhbGxIABJOCh5zZXR1cF92bV9l" + + "bnZpcm9ubWVudF90b29sX2NhbGwYISABKAsyJC5hZ2VudC52MS5TZXR1cFZtRW52aXJvbm1lbnRU" + + "b29sQ2FsbEgAEjoKE3RydW5jYXRlZF90b29sX2NhbGwYIiABKAsyGy5hZ2VudC52MS5UcnVuY2F0" + + "ZWRUb29sQ2FsbEgAElAKH3N0YXJ0X2dyaW5kX2V4ZWN1dGlvbl90b29sX2NhbGwYIyABKAsyJS5h" + + "Z2VudC52MS5TdGFydEdyaW5kRXhlY3V0aW9uVG9vbENhbGxIABJOCh5zdGFydF9ncmluZF9wbGFu" + + "bmluZ190b29sX2NhbGwYJCABKAsyJC5hZ2VudC52MS5TdGFydEdyaW5kUGxhbm5pbmdUb29sQ2Fs" + + "bEgAQgYKBHRvb2wiFwoVVHJ1bmNhdGVkVG9vbENhbGxBcmdzIhoKGFRydW5jYXRlZFRvb2xDYWxs" + + "U3VjY2VzcyInChZUcnVuY2F0ZWRUb29sQ2FsbEVycm9yEg0KBWVycm9yGAEgASgJIo0BChdUcnVu" + + "Y2F0ZWRUb29sQ2FsbFJlc3VsdBI1CgdzdWNjZXNzGAEgASgLMiIuYWdlbnQudjEuVHJ1bmNhdGVk" + + "VG9vbENhbGxTdWNjZXNzSAASMQoFZXJyb3IYAiABKAsyIC5hZ2VudC52MS5UcnVuY2F0ZWRUb29s" + + "Q2FsbEVycm9ySABCCAoGcmVzdWx0IpQBChFUcnVuY2F0ZWRUb29sQ2FsbBIdChVvcmlnaW5hbF9z" + + "dGVwX2Jsb2JfaWQYASABKAwSLQoEYXJncxgCIAEoCzIfLmFnZW50LnYxLlRydW5jYXRlZFRvb2xD" + + "YWxsQXJncxIxCgZyZXN1bHQYAyABKAsyIS5hZ2VudC52MS5UcnVuY2F0ZWRUb29sQ2FsbFJlc3Vs" + + "dCLRAQoNVG9vbENhbGxEZWx0YRI9ChVzaGVsbF90b29sX2NhbGxfZGVsdGEYASABKAsyHC5hZ2Vu" + + "dC52MS5TaGVsbFRvb2xDYWxsRGVsdGFIABI7ChR0YXNrX3Rvb2xfY2FsbF9kZWx0YRgCIAEoCzIb" + + "LmFnZW50LnYxLlRhc2tUb29sQ2FsbERlbHRhSAASOwoUZWRpdF90b29sX2NhbGxfZGVsdGEYAyAB" + + "KAsyGy5hZ2VudC52MS5FZGl0VG9vbENhbGxEZWx0YUgAQgcKBWRlbHRhIrYBChBDb252ZXJzYXRp" + + "b25TdGVwEjcKEWFzc2lzdGFudF9tZXNzYWdlGAEgASgLMhouYWdlbnQudjEuQXNzaXN0YW50TWVz" + + "c2FnZUgAEicKCXRvb2xfY2FsbBgCIAEoCzISLmFnZW50LnYxLlRvb2xDYWxsSAASNQoQdGhpbmtp" + + "bmdfbWVzc2FnZRgDIAEoCzIZLmFnZW50LnYxLlRoaW5raW5nTWVzc2FnZUgAQgkKB21lc3NhZ2Ui" + + "gQQKEkNvbnZlcnNhdGlvbkFjdGlvbhI6ChN1c2VyX21lc3NhZ2VfYWN0aW9uGAEgASgLMhsuYWdl" + + "bnQudjEuVXNlck1lc3NhZ2VBY3Rpb25IABIvCg1yZXN1bWVfYWN0aW9uGAIgASgLMhYuYWdlbnQu" + + "djEuUmVzdW1lQWN0aW9uSAASLwoNY2FuY2VsX2FjdGlvbhgDIAEoCzIWLmFnZW50LnYxLkNhbmNl" + + "bEFjdGlvbkgAEjUKEHN1bW1hcml6ZV9hY3Rpb24YBCABKAsyGS5hZ2VudC52MS5TdW1tYXJpemVB" + + "Y3Rpb25IABI8ChRzaGVsbF9jb21tYW5kX2FjdGlvbhgFIAEoCzIcLmFnZW50LnYxLlNoZWxsQ29t" + + "bWFuZEFjdGlvbkgAEjYKEXN0YXJ0X3BsYW5fYWN0aW9uGAYgASgLMhkuYWdlbnQudjEuU3RhcnRQ" + + "bGFuQWN0aW9uSAASOgoTZXhlY3V0ZV9wbGFuX2FjdGlvbhgHIAEoCzIbLmFnZW50LnYxLkV4ZWN1" + + "dGVQbGFuQWN0aW9uSAASWgokYXN5bmNfYXNrX3F1ZXN0aW9uX2NvbXBsZXRpb25fYWN0aW9uGAgg" + + "ASgLMiouYWdlbnQudjEuQXN5bmNBc2tRdWVzdGlvbkNvbXBsZXRpb25BY3Rpb25IAEIICgZhY3Rp" + + "b24ivwEKEVVzZXJNZXNzYWdlQWN0aW9uEisKDHVzZXJfbWVzc2FnZRgBIAEoCzIVLmFnZW50LnYx" + + "LlVzZXJNZXNzYWdlEjEKD3JlcXVlc3RfY29udGV4dBgCIAEoCzIYLmFnZW50LnYxLlJlcXVlc3RD" + + "b250ZXh0EikKHHNlbmRfdG9faW50ZXJhY3Rpb25fbGlzdGVuZXIYAyABKAhIAIgBAUIfCh1fc2Vu" + + "ZF90b19pbnRlcmFjdGlvbl9saXN0ZW5lciIOCgxDYW5jZWxBY3Rpb24iQQoMUmVzdW1lQWN0aW9u" + + "EjEKD3JlcXVlc3RfY29udGV4dBgCIAEoCzIYLmFnZW50LnYxLlJlcXVlc3RDb250ZXh0IqABCiBB" + + "c3luY0Fza1F1ZXN0aW9uQ29tcGxldGlvbkFjdGlvbhIdChVvcmlnaW5hbF90b29sX2NhbGxfaWQY" + + "ASABKAkSMAoNb3JpZ2luYWxfYXJncxgCIAEoCzIZLmFnZW50LnYxLkFza1F1ZXN0aW9uQXJncxIr" + + "CgZyZXN1bHQYAyABKAsyGy5hZ2VudC52MS5Bc2tRdWVzdGlvblJlc3VsdCIRCg9TdW1tYXJpemVB" + + "Y3Rpb24iVAoSU2hlbGxDb21tYW5kQWN0aW9uEi0KDXNoZWxsX2NvbW1hbmQYASABKAsyFi5hZ2Vu" + + "dC52MS5TaGVsbENvbW1hbmQSDwoHZXhlY19pZBgCIAEoCSKCAQoPU3RhcnRQbGFuQWN0aW9uEisK" + + "DHVzZXJfbWVzc2FnZRgBIAEoCzIVLmFnZW50LnYxLlVzZXJNZXNzYWdlEjEKD3JlcXVlc3RfY29u" + + "dGV4dBgCIAEoCzIYLmFnZW50LnYxLlJlcXVlc3RDb250ZXh0Eg8KB2lzX3NwZWMYAyABKAgi4gEK" + + "EUV4ZWN1dGVQbGFuQWN0aW9uEjEKD3JlcXVlc3RfY29udGV4dBgBIAEoCzIYLmFnZW50LnYxLlJl" + + "cXVlc3RDb250ZXh0Ei0KBHBsYW4YAiABKAsyGi5hZ2VudC52MS5Db252ZXJzYXRpb25QbGFuSACI" + + "AQESGgoNcGxhbl9maWxlX3VyaRgDIAEoCUgBiAEBEh4KEXBsYW5fZmlsZV9jb250ZW50GAQgASgJ" + + "SAKIAQFCBwoFX3BsYW5CEAoOX3BsYW5fZmlsZV91cmlCFAoSX3BsYW5fZmlsZV9jb250ZW50IugC" + + "CgtVc2VyTWVzc2FnZRIMCgR0ZXh0GAEgASgJEhIKCm1lc3NhZ2VfaWQYAiABKAkSOAoQc2VsZWN0" + + "ZWRfY29udGV4dBgDIAEoCzIZLmFnZW50LnYxLlNlbGVjdGVkQ29udGV4dEgAiAEBEgwKBG1vZGUY" + + "BCABKAUSHQoQaXNfc2ltdWxhdGVkX21zZxgFIAEoCEgBiAEBEh8KEmJlc3Rfb2Zfbl9ncm91cF9p" + + "ZBgGIAEoCUgCiAEBEigKG3RyeV91c2VfYmVzdF9vZl9uX3Byb21vdGlvbhgHIAEoCEgDiAEBEhYK" + + "CXJpY2hfdGV4dBgIIAEoCUgEiAEBQhMKEV9zZWxlY3RlZF9jb250ZXh0QhMKEV9pc19zaW11bGF0" + + "ZWRfbXNnQhUKE19iZXN0X29mX25fZ3JvdXBfaWRCHgocX3RyeV91c2VfYmVzdF9vZl9uX3Byb21v" + + "dGlvbkIMCgpfcmljaF90ZXh0IiAKEEFzc2lzdGFudE1lc3NhZ2USDAoEdGV4dBgBIAEoCSI0Cg9U" + + "aGlua2luZ01lc3NhZ2USDAoEdGV4dBgBIAEoCRITCgtkdXJhdGlvbl9tcxgCIAEoDSIfCgxTaGVs" + + "bENvbW1hbmQSDwoHY29tbWFuZBgBIAEoCSJACgtTaGVsbE91dHB1dBIOCgZzdGRvdXQYASABKAkS" + + "DgoGc3RkZXJyGAIgASgJEhEKCWV4aXRfY29kZRgDIAEoBSKiAQoQQ29udmVyc2F0aW9uVHVybhJC" + + "ChdhZ2VudF9jb252ZXJzYXRpb25fdHVybhgBIAEoCzIfLmFnZW50LnYxLkFnZW50Q29udmVyc2F0" + + "aW9uVHVybkgAEkIKF3NoZWxsX2NvbnZlcnNhdGlvbl90dXJuGAIgASgLMh8uYWdlbnQudjEuU2hl" + + "bGxDb252ZXJzYXRpb25UdXJuSABCBgoEdHVybiIgChBDb252ZXJzYXRpb25QbGFuEgwKBHBsYW4Y" + + "ASABKAkivQEKGUNvbnZlcnNhdGlvblR1cm5TdHJ1Y3R1cmUSSwoXYWdlbnRfY29udmVyc2F0aW9u" + + "X3R1cm4YASABKAsyKC5hZ2VudC52MS5BZ2VudENvbnZlcnNhdGlvblR1cm5TdHJ1Y3R1cmVIABJL" + + "ChdzaGVsbF9jb252ZXJzYXRpb25fdHVybhgCIAEoCzIoLmFnZW50LnYxLlNoZWxsQ29udmVyc2F0" + + "aW9uVHVyblN0cnVjdHVyZUgAQgYKBHR1cm4ilwEKFUFnZW50Q29udmVyc2F0aW9uVHVybhIrCgx1" + + "c2VyX21lc3NhZ2UYASABKAsyFS5hZ2VudC52MS5Vc2VyTWVzc2FnZRIpCgVzdGVwcxgCIAMoCzIa" + + "LmFnZW50LnYxLkNvbnZlcnNhdGlvblN0ZXASFwoKcmVxdWVzdF9pZBgDIAEoCUgAiAEBQg0KC19y" + + "ZXF1ZXN0X2lkIm0KHkFnZW50Q29udmVyc2F0aW9uVHVyblN0cnVjdHVyZRIUCgx1c2VyX21lc3Nh" + + "Z2UYASABKAwSDQoFc3RlcHMYAiADKAwSFwoKcmVxdWVzdF9pZBgDIAEoCUgAiAEBQg0KC19yZXF1" + + "ZXN0X2lkInMKFVNoZWxsQ29udmVyc2F0aW9uVHVybhItCg1zaGVsbF9jb21tYW5kGAEgASgLMhYu" + + "YWdlbnQudjEuU2hlbGxDb21tYW5kEisKDHNoZWxsX291dHB1dBgCIAEoCzIVLmFnZW50LnYxLlNo" + + "ZWxsT3V0cHV0Ik0KHlNoZWxsQ29udmVyc2F0aW9uVHVyblN0cnVjdHVyZRIVCg1zaGVsbF9jb21t" + + "YW5kGAEgASgMEhQKDHNoZWxsX291dHB1dBgCIAEoDCImChNDb252ZXJzYXRpb25TdW1tYXJ5Eg8K" + + "B3N1bW1hcnkYASABKAkieAoaQ29udmVyc2F0aW9uU3VtbWFyeUFyY2hpdmUSGwoTc3VtbWFyaXpl" + + "ZF9tZXNzYWdlcxgBIAMoDBIPCgdzdW1tYXJ5GAIgASgJEhMKC3dpbmRvd190YWlsGAMgASgNEhcK" + + "D3N1bW1hcnlfbWVzc2FnZRgEIAEoDCJDChhDb252ZXJzYXRpb25Ub2tlbkRldGFpbHMSEwoLdXNl" + + "ZF90b2tlbnMYASABKA0SEgoKbWF4X3Rva2VucxgCIAEoDSJfCglGaWxlU3RhdGUSFAoHY29udGVu" + + "dBgBIAEoCUgAiAEBEhwKD2luaXRpYWxfY29udGVudBgCIAEoCUgBiAEBQgoKCF9jb250ZW50QhIK" + + "EF9pbml0aWFsX2NvbnRlbnQiaAoSRmlsZVN0YXRlU3RydWN0dXJlEhQKB2NvbnRlbnQYASABKAxI" + + "AIgBARIcCg9pbml0aWFsX2NvbnRlbnQYAiABKAxIAYgBAUIKCghfY29udGVudEISChBfaW5pdGlh" + + "bF9jb250ZW50IjcKClN0ZXBUaW1pbmcSEwoLZHVyYXRpb25fbXMYASABKAQSFAoMdGltZXN0YW1w" + + "X21zGAIgASgEIvYEChFDb252ZXJzYXRpb25TdGF0ZRIhChlyb290X3Byb21wdF9tZXNzYWdlc19q" + + "c29uGAEgAygJEikKBXR1cm5zGAggAygLMhouYWdlbnQudjEuQ29udmVyc2F0aW9uVHVybhIhCgV0" + + "b2RvcxgDIAMoCzISLmFnZW50LnYxLlRvZG9JdGVtEhoKEnBlbmRpbmdfdG9vbF9jYWxscxgEIAMo" + + "CRI5Cg10b2tlbl9kZXRhaWxzGAUgASgLMiIuYWdlbnQudjEuQ29udmVyc2F0aW9uVG9rZW5EZXRh" + + "aWxzEjMKB3N1bW1hcnkYBiABKAsyHS5hZ2VudC52MS5Db252ZXJzYXRpb25TdW1tYXJ5SACIAQES" + + "LQoEcGxhbhgHIAEoCzIaLmFnZW50LnYxLkNvbnZlcnNhdGlvblBsYW5IAYgBARJCCg9zdW1tYXJ5" + + "X2FyY2hpdmUYCSABKAsyJC5hZ2VudC52MS5Db252ZXJzYXRpb25TdW1tYXJ5QXJjaGl2ZUgCiAEB" + + "EkAKC2ZpbGVfc3RhdGVzGAogAygLMisuYWdlbnQudjEuQ29udmVyc2F0aW9uU3RhdGUuRmlsZVN0" + + "YXRlc0VudHJ5Ej4KEHN1bW1hcnlfYXJjaGl2ZXMYCyADKAsyJC5hZ2VudC52MS5Db252ZXJzYXRp" + + "b25TdW1tYXJ5QXJjaGl2ZRpGCg9GaWxlU3RhdGVzRW50cnkSCwoDa2V5GAEgASgJEiIKBXZhbHVl" + + "GAIgASgLMhMuYWdlbnQudjEuRmlsZVN0YXRlOgI4AUIKCghfc3VtbWFyeUIHCgVfcGxhbkISChBf" + + "c3VtbWFyeV9hcmNoaXZlIscBChZTdWJhZ2VudFBlcnNpc3RlZFN0YXRlEkAKEmNvbnZlcnNhdGlv" + + "bl9zdGF0ZRgBIAEoCzIkLmFnZW50LnYxLkNvbnZlcnNhdGlvblN0YXRlU3RydWN0dXJlEhwKFGNy" + + "ZWF0ZWRfdGltZXN0YW1wX21zGAIgASgEEh4KFmxhc3RfdXNlZF90aW1lc3RhbXBfbXMYAyABKAQS" + + "LQoNc3ViYWdlbnRfdHlwZRgEIAEoCzIWLmFnZW50LnYxLlN1YmFnZW50VHlwZSK3BwoaQ29udmVy" + + "c2F0aW9uU3RhdGVTdHJ1Y3R1cmUSEQoJdHVybnNfb2xkGAIgAygMEiEKGXJvb3RfcHJvbXB0X21l" + + "c3NhZ2VzX2pzb24YASADKAwSDQoFdHVybnMYCCADKAwSDQoFdG9kb3MYAyADKAwSGgoScGVuZGlu" + + "Z190b29sX2NhbGxzGAQgAygJEjkKDXRva2VuX2RldGFpbHMYBSABKAsyIi5hZ2VudC52MS5Db252" + + "ZXJzYXRpb25Ub2tlbkRldGFpbHMSFAoHc3VtbWFyeRgGIAEoDEgAiAEBEhEKBHBsYW4YByABKAxI" + + "AYgBARIfChdwcmV2aW91c193b3Jrc3BhY2VfdXJpcxgJIAMoCRIRCgRtb2RlGAogASgFSAKIAQES" + + "HAoPc3VtbWFyeV9hcmNoaXZlGAsgASgMSAOIAQESSQoLZmlsZV9zdGF0ZXMYDCADKAsyNC5hZ2Vu" + + "dC52MS5Db252ZXJzYXRpb25TdGF0ZVN0cnVjdHVyZS5GaWxlU3RhdGVzRW50cnkSTgoOZmlsZV9z" + + "dGF0ZXNfdjIYDyADKAsyNi5hZ2VudC52MS5Db252ZXJzYXRpb25TdGF0ZVN0cnVjdHVyZS5GaWxl" + + "U3RhdGVzVjJFbnRyeRIYChBzdW1tYXJ5X2FyY2hpdmVzGA0gAygMEioKDHR1cm5fdGltaW5ncxgO" + + "IAMoCzIULmFnZW50LnYxLlN0ZXBUaW1pbmcSUQoPc3ViYWdlbnRfc3RhdGVzGBAgAygLMjguYWdl" + + "bnQudjEuQ29udmVyc2F0aW9uU3RhdGVTdHJ1Y3R1cmUuU3ViYWdlbnRTdGF0ZXNFbnRyeRIaChJz" + + "ZWxmX3N1bW1hcnlfY291bnQYESABKA0SEgoKcmVhZF9wYXRocxgSIAMoCRoxCg9GaWxlU3RhdGVz" + + "RW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgMOgI4ARpRChFGaWxlU3RhdGVzVjJFbnRy" + + "eRILCgNrZXkYASABKAkSKwoFdmFsdWUYAiABKAsyHC5hZ2VudC52MS5GaWxlU3RhdGVTdHJ1Y3R1" + + "cmU6AjgBGlcKE1N1YmFnZW50U3RhdGVzRW50cnkSCwoDa2V5GAEgASgJEi8KBXZhbHVlGAIgASgL" + + "MiAuYWdlbnQudjEuU3ViYWdlbnRQZXJzaXN0ZWRTdGF0ZToCOAFCCgoIX3N1bW1hcnlCBwoFX3Bs" + + "YW5CBwoFX21vZGVCEgoQX3N1bW1hcnlfYXJjaGl2ZSIRCg9UaGlua2luZ0RldGFpbHMiSAoRQXBp" + + "S2V5Q3JlZGVudGlhbHMSDwoHYXBpX2tleRgBIAEoCRIVCghiYXNlX3VybBgCIAEoCUgAiAEBQgsK" + + "CV9iYXNlX3VybCJJChBBenVyZUNyZWRlbnRpYWxzEg8KB2FwaV9rZXkYASABKAkSEAoIYmFzZV91" + + "cmwYAiABKAkSEgoKZGVwbG95bWVudBgDIAEoCSJ6ChJCZWRyb2NrQ3JlZGVudGlhbHMSEgoKYWNj" + + "ZXNzX2tleRgBIAEoCRISCgpzZWNyZXRfa2V5GAIgASgJEg4KBnJlZ2lvbhgDIAEoCRIaCg1zZXNz" + + "aW9uX3Rva2VuGAQgASgJSACIAQFCEAoOX3Nlc3Npb25fdG9rZW4isQMKDE1vZGVsRGV0YWlscxIQ" + + "Cghtb2RlbF9pZBgBIAEoCRIYChBkaXNwbGF5X21vZGVsX2lkGAMgASgJEhQKDGRpc3BsYXlfbmFt" + + "ZRgEIAEoCRIaChJkaXNwbGF5X25hbWVfc2hvcnQYBSABKAkSDwoHYWxpYXNlcxgGIAMoCRI4ChB0" + + "aGlua2luZ19kZXRhaWxzGAIgASgLMhkuYWdlbnQudjEuVGhpbmtpbmdEZXRhaWxzSAGIAQESFQoI" + + "bWF4X21vZGUYByABKAhIAogBARI6ChNhcGlfa2V5X2NyZWRlbnRpYWxzGAggASgLMhsuYWdlbnQu" + + "djEuQXBpS2V5Q3JlZGVudGlhbHNIABI3ChFhenVyZV9jcmVkZW50aWFscxgJIAEoCzIaLmFnZW50" + + "LnYxLkF6dXJlQ3JlZGVudGlhbHNIABI7ChNiZWRyb2NrX2NyZWRlbnRpYWxzGAogASgLMhwuYWdl" + + "bnQudjEuQmVkcm9ja0NyZWRlbnRpYWxzSABCDQoLY3JlZGVudGlhbHNCEwoRX3RoaW5raW5nX2Rl" + + "dGFpbHNCCwoJX21heF9tb2RlIrcCCg5SZXF1ZXN0ZWRNb2RlbBIQCghtb2RlbF9pZBgBIAEoCRIQ" + + "CghtYXhfbW9kZRgCIAEoCBJACgpwYXJhbWV0ZXJzGAMgAygLMiwuYWdlbnQudjEuUmVxdWVzdGVk" + + "TW9kZWxfTW9kZWxQYXJhbWV0ZXJieXRlcxI6ChNhcGlfa2V5X2NyZWRlbnRpYWxzGAQgASgLMhsu" + + "YWdlbnQudjEuQXBpS2V5Q3JlZGVudGlhbHNIABI3ChFhenVyZV9jcmVkZW50aWFscxgFIAEoCzIa" + + "LmFnZW50LnYxLkF6dXJlQ3JlZGVudGlhbHNIABI7ChNiZWRyb2NrX2NyZWRlbnRpYWxzGAYgASgL" + + "MhwuYWdlbnQudjEuQmVkcm9ja0NyZWRlbnRpYWxzSABCDQoLY3JlZGVudGlhbHMiPwoiUmVxdWVz" + + "dGVkTW9kZWxfTW9kZWxQYXJhbWV0ZXJieXRlcxIKCgJpZBgBIAEoCRINCgV2YWx1ZRgCIAEoCSK5" + + "BAoPQWdlbnRSdW5SZXF1ZXN0EkAKEmNvbnZlcnNhdGlvbl9zdGF0ZRgBIAEoCzIkLmFnZW50LnYx" + + "LkNvbnZlcnNhdGlvblN0YXRlU3RydWN0dXJlEiwKBmFjdGlvbhgCIAEoCzIcLmFnZW50LnYxLkNv" + + "bnZlcnNhdGlvbkFjdGlvbhItCg1tb2RlbF9kZXRhaWxzGAMgASgLMhYuYWdlbnQudjEuTW9kZWxE" + + "ZXRhaWxzEjYKD3JlcXVlc3RlZF9tb2RlbBgJIAEoCzIYLmFnZW50LnYxLlJlcXVlc3RlZE1vZGVs" + + "SACIAQESJQoJbWNwX3Rvb2xzGAQgASgLMhIuYWdlbnQudjEuTWNwVG9vbHMSHAoPY29udmVyc2F0" + + "aW9uX2lkGAUgASgJSAGIAQESRAoXbWNwX2ZpbGVfc3lzdGVtX29wdGlvbnMYBiABKAsyHi5hZ2Vu" + + "dC52MS5NY3BGaWxlU3lzdGVtT3B0aW9uc0gCiAEBEjIKDXNraWxsX29wdGlvbnMYByABKAsyFi5h" + + "Z2VudC52MS5Ta2lsbE9wdGlvbnNIA4gBARIhChRjdXN0b21fc3lzdGVtX3Byb21wdBgIIAEoCUgE" + + "iAEBQhIKEF9yZXF1ZXN0ZWRfbW9kZWxCEgoQX2NvbnZlcnNhdGlvbl9pZEIaChhfbWNwX2ZpbGVf" + + "c3lzdGVtX29wdGlvbnNCEAoOX3NraWxsX29wdGlvbnNCFwoVX2N1c3RvbV9zeXN0ZW1fcHJvbXB0" + + "Ih8KD1RleHREZWx0YVVwZGF0ZRIMCgR0ZXh0GAEgASgJImYKFVRvb2xDYWxsU3RhcnRlZFVwZGF0" + + "ZRIPCgdjYWxsX2lkGAEgASgJEiUKCXRvb2xfY2FsbBgCIAEoCzISLmFnZW50LnYxLlRvb2xDYWxs" + + "EhUKDW1vZGVsX2NhbGxfaWQYAyABKAkiaAoXVG9vbENhbGxDb21wbGV0ZWRVcGRhdGUSDwoHY2Fs" + + "bF9pZBgBIAEoCRIlCgl0b29sX2NhbGwYAiABKAsyEi5hZ2VudC52MS5Ub29sQ2FsbBIVCg1tb2Rl" + + "bF9jYWxsX2lkGAMgASgJIm8KE1Rvb2xDYWxsRGVsdGFVcGRhdGUSDwoHY2FsbF9pZBgBIAEoCRIw" + + "Cg90b29sX2NhbGxfZGVsdGEYAiABKAsyFy5hZ2VudC52MS5Ub29sQ2FsbERlbHRhEhUKDW1vZGVs" + + "X2NhbGxfaWQYAyABKAkifwoVUGFydGlhbFRvb2xDYWxsVXBkYXRlEg8KB2NhbGxfaWQYASABKAkS" + + "JQoJdG9vbF9jYWxsGAIgASgLMhIuYWdlbnQudjEuVG9vbENhbGwSFwoPYXJnc190ZXh0X2RlbHRh" + + "GAMgASgJEhUKDW1vZGVsX2NhbGxfaWQYBCABKAkiIwoTVGhpbmtpbmdEZWx0YVVwZGF0ZRIMCgR0" + + "ZXh0GAEgASgJIjcKF1RoaW5raW5nQ29tcGxldGVkVXBkYXRlEhwKFHRoaW5raW5nX2R1cmF0aW9u" + + "X21zGAEgASgFIiIKEFRva2VuRGVsdGFVcGRhdGUSDgoGdG9rZW5zGAEgASgFIiAKDVN1bW1hcnlV" + + "cGRhdGUSDwoHc3VtbWFyeRgBIAEoCSIWChRTdW1tYXJ5U3RhcnRlZFVwZGF0ZSIRCg9IZWFydGJl" + + "YXRVcGRhdGUiGAoWU3VtbWFyeUNvbXBsZXRlZFVwZGF0ZSLXAQoWU2hlbGxPdXRwdXREZWx0YVVw" + + "ZGF0ZRItCgZzdGRvdXQYASABKAsyGy5hZ2VudC52MS5TaGVsbFN0cmVhbVN0ZG91dEgAEi0KBnN0" + + "ZGVychgCIAEoCzIbLmFnZW50LnYxLlNoZWxsU3RyZWFtU3RkZXJySAASKQoEZXhpdBgDIAEoCzIZ" + + "LmFnZW50LnYxLlNoZWxsU3RyZWFtRXhpdEgAEisKBXN0YXJ0GAQgASgLMhouYWdlbnQudjEuU2hl" + + "bGxTdHJlYW1TdGFydEgAQgcKBWV2ZW50IhEKD1R1cm5FbmRlZFVwZGF0ZSJIChlVc2VyTWVzc2Fn" + + "ZUFwcGVuZGVkVXBkYXRlEisKDHVzZXJfbWVzc2FnZRgBIAEoCzIVLmFnZW50LnYxLlVzZXJNZXNz" + + "YWdlIiQKEVN0ZXBTdGFydGVkVXBkYXRlEg8KB3N0ZXBfaWQYASABKAQiQAoTU3RlcENvbXBsZXRl" + + "ZFVwZGF0ZRIPCgdzdGVwX2lkGAEgASgEEhgKEHN0ZXBfZHVyYXRpb25fbXMYAiABKAMi7wcKEUlu" + + "dGVyYWN0aW9uVXBkYXRlEi8KCnRleHRfZGVsdGEYASABKAsyGS5hZ2VudC52MS5UZXh0RGVsdGFV" + + "cGRhdGVIABI8ChFwYXJ0aWFsX3Rvb2xfY2FsbBgHIAEoCzIfLmFnZW50LnYxLlBhcnRpYWxUb29s" + + "Q2FsbFVwZGF0ZUgAEjgKD3Rvb2xfY2FsbF9kZWx0YRgPIAEoCzIdLmFnZW50LnYxLlRvb2xDYWxs" + + "RGVsdGFVcGRhdGVIABI8ChF0b29sX2NhbGxfc3RhcnRlZBgCIAEoCzIfLmFnZW50LnYxLlRvb2xD" + + "YWxsU3RhcnRlZFVwZGF0ZUgAEkAKE3Rvb2xfY2FsbF9jb21wbGV0ZWQYAyABKAsyIS5hZ2VudC52" + + "MS5Ub29sQ2FsbENvbXBsZXRlZFVwZGF0ZUgAEjcKDnRoaW5raW5nX2RlbHRhGAQgASgLMh0uYWdl" + + "bnQudjEuVGhpbmtpbmdEZWx0YVVwZGF0ZUgAEj8KEnRoaW5raW5nX2NvbXBsZXRlZBgFIAEoCzIh" + + "LmFnZW50LnYxLlRoaW5raW5nQ29tcGxldGVkVXBkYXRlSAASRAoVdXNlcl9tZXNzYWdlX2FwcGVu" + + "ZGVkGAYgASgLMiMuYWdlbnQudjEuVXNlck1lc3NhZ2VBcHBlbmRlZFVwZGF0ZUgAEjEKC3Rva2Vu" + + "X2RlbHRhGAggASgLMhouYWdlbnQudjEuVG9rZW5EZWx0YVVwZGF0ZUgAEioKB3N1bW1hcnkYCSAB" + + "KAsyFy5hZ2VudC52MS5TdW1tYXJ5VXBkYXRlSAASOQoPc3VtbWFyeV9zdGFydGVkGAogASgLMh4u" + + "YWdlbnQudjEuU3VtbWFyeVN0YXJ0ZWRVcGRhdGVIABI9ChFzdW1tYXJ5X2NvbXBsZXRlZBgLIAEo" + + "CzIgLmFnZW50LnYxLlN1bW1hcnlDb21wbGV0ZWRVcGRhdGVIABI+ChJzaGVsbF9vdXRwdXRfZGVs" + + "dGEYDCABKAsyIC5hZ2VudC52MS5TaGVsbE91dHB1dERlbHRhVXBkYXRlSAASLgoJaGVhcnRiZWF0" + + "GA0gASgLMhkuYWdlbnQudjEuSGVhcnRiZWF0VXBkYXRlSAASLwoKdHVybl9lbmRlZBgOIAEoCzIZ" + + "LmFnZW50LnYxLlR1cm5FbmRlZFVwZGF0ZUgAEjMKDHN0ZXBfc3RhcnRlZBgQIAEoCzIbLmFnZW50" + + "LnYxLlN0ZXBTdGFydGVkVXBkYXRlSAASNwoOc3RlcF9jb21wbGV0ZWQYESABKAsyHS5hZ2VudC52" + + "MS5TdGVwQ29tcGxldGVkVXBkYXRlSABCCQoHbWVzc2FnZSKaBAoQSW50ZXJhY3Rpb25RdWVyeRIK" + + "CgJpZBgBIAEoDRJDChh3ZWJfc2VhcmNoX3JlcXVlc3RfcXVlcnkYAiABKAsyHy5hZ2VudC52MS5X" + + "ZWJTZWFyY2hSZXF1ZXN0UXVlcnlIABJPCh5hc2tfcXVlc3Rpb25faW50ZXJhY3Rpb25fcXVlcnkY" + + "AyABKAsyJS5hZ2VudC52MS5Bc2tRdWVzdGlvbkludGVyYWN0aW9uUXVlcnlIABJFChlzd2l0Y2hf" + + "bW9kZV9yZXF1ZXN0X3F1ZXJ5GAQgASgLMiAuYWdlbnQudjEuU3dpdGNoTW9kZVJlcXVlc3RRdWVy" + + "eUgAEkMKGGV4YV9zZWFyY2hfcmVxdWVzdF9xdWVyeRgFIAEoCzIfLmFnZW50LnYxLkV4YVNlYXJj" + + "aFJlcXVlc3RRdWVyeUgAEkEKF2V4YV9mZXRjaF9yZXF1ZXN0X3F1ZXJ5GAYgASgLMh4uYWdlbnQu" + + "djEuRXhhRmV0Y2hSZXF1ZXN0UXVlcnlIABJFChljcmVhdGVfcGxhbl9yZXF1ZXN0X3F1ZXJ5GAcg" + + "ASgLMiAuYWdlbnQudjEuQ3JlYXRlUGxhblJlcXVlc3RRdWVyeUgAEkUKGXNldHVwX3ZtX2Vudmly" + + "b25tZW50X2FyZ3MYCCABKAsyIC5hZ2VudC52MS5TZXR1cFZtRW52aXJvbm1lbnRBcmdzSABCBwoF" + + "cXVlcnkixgQKE0ludGVyYWN0aW9uUmVzcG9uc2USCgoCaWQYASABKA0SSQobd2ViX3NlYXJjaF9y" + + "ZXF1ZXN0X3Jlc3BvbnNlGAIgASgLMiIuYWdlbnQudjEuV2ViU2VhcmNoUmVxdWVzdFJlc3BvbnNl" + + "SAASVQohYXNrX3F1ZXN0aW9uX2ludGVyYWN0aW9uX3Jlc3BvbnNlGAMgASgLMiguYWdlbnQudjEu" + + "QXNrUXVlc3Rpb25JbnRlcmFjdGlvblJlc3BvbnNlSAASSwocc3dpdGNoX21vZGVfcmVxdWVzdF9y" + + "ZXNwb25zZRgEIAEoCzIjLmFnZW50LnYxLlN3aXRjaE1vZGVSZXF1ZXN0UmVzcG9uc2VIABJJChtl" + + "eGFfc2VhcmNoX3JlcXVlc3RfcmVzcG9uc2UYBSABKAsyIi5hZ2VudC52MS5FeGFTZWFyY2hSZXF1" + + "ZXN0UmVzcG9uc2VIABJHChpleGFfZmV0Y2hfcmVxdWVzdF9yZXNwb25zZRgGIAEoCzIhLmFnZW50" + + "LnYxLkV4YUZldGNoUmVxdWVzdFJlc3BvbnNlSAASSwocY3JlYXRlX3BsYW5fcmVxdWVzdF9yZXNw" + + "b25zZRgHIAEoCzIjLmFnZW50LnYxLkNyZWF0ZVBsYW5SZXF1ZXN0UmVzcG9uc2VIABJJChtzZXR1" + + "cF92bV9lbnZpcm9ubWVudF9yZXN1bHQYCCABKAsyIi5hZ2VudC52MS5TZXR1cFZtRW52aXJvbm1l" + + "bnRSZXN1bHRIAEIICgZyZXN1bHQiXAobQXNrUXVlc3Rpb25JbnRlcmFjdGlvblF1ZXJ5EicKBGFy" + + "Z3MYASABKAsyGS5hZ2VudC52MS5Bc2tRdWVzdGlvbkFyZ3MSFAoMdG9vbF9jYWxsX2lkGAIgASgJ" + + "Ik0KHkFza1F1ZXN0aW9uSW50ZXJhY3Rpb25SZXNwb25zZRIrCgZyZXN1bHQYASABKAsyGy5hZ2Vu" + + "dC52MS5Bc2tRdWVzdGlvblJlc3VsdCIRCg9DbGllbnRIZWFydGJlYXQixgQKDlByZXdhcm1SZXF1" + + "ZXN0Ei0KDW1vZGVsX2RldGFpbHMYASABKAsyFi5hZ2VudC52MS5Nb2RlbERldGFpbHMSNgoPcmVx" + + "dWVzdGVkX21vZGVsGAkgASgLMhguYWdlbnQudjEuUmVxdWVzdGVkTW9kZWxIAIgBARIcCg9jb252" + + "ZXJzYXRpb25faWQYAiABKAlIAYgBARJAChJjb252ZXJzYXRpb25fc3RhdGUYAyABKAsyJC5hZ2Vu" + + "dC52MS5Db252ZXJzYXRpb25TdGF0ZVN0cnVjdHVyZRIlCgltY3BfdG9vbHMYBCABKAsyEi5hZ2Vu" + + "dC52MS5NY3BUb29scxJEChdtY3BfZmlsZV9zeXN0ZW1fb3B0aW9ucxgFIAEoCzIeLmFnZW50LnYx" + + "Lk1jcEZpbGVTeXN0ZW1PcHRpb25zSAKIAQESHwoSYmVzdF9vZl9uX2dyb3VwX2lkGAYgASgJSAOI" + + "AQESKAobdHJ5X3VzZV9iZXN0X29mX25fcHJvbW90aW9uGAcgASgISASIAQESIQoUY3VzdG9tX3N5" + + "c3RlbV9wcm9tcHQYCCABKAlIBYgBAUISChBfcmVxdWVzdGVkX21vZGVsQhIKEF9jb252ZXJzYXRp" + + "b25faWRCGgoYX21jcF9maWxlX3N5c3RlbV9vcHRpb25zQhUKE19iZXN0X29mX25fZ3JvdXBfaWRC" + + "HgocX3RyeV91c2VfYmVzdF9vZl9uX3Byb21vdGlvbkIXChVfY3VzdG9tX3N5c3RlbV9wcm9tcHQi" + + "HQoPRXhlY1NlcnZlckFib3J0EgoKAmlkGAEgASgNIlEKGEV4ZWNTZXJ2ZXJDb250cm9sTWVzc2Fn" + + "ZRIqCgVhYm9ydBgBIAEoCzIZLmFnZW50LnYxLkV4ZWNTZXJ2ZXJBYm9ydEgAQgkKB21lc3NhZ2Ui" + + "+AMKEkFnZW50Q2xpZW50TWVzc2FnZRIwCgtydW5fcmVxdWVzdBgBIAEoCzIZLmFnZW50LnYxLkFn" + + "ZW50UnVuUmVxdWVzdEgAEjoKE2V4ZWNfY2xpZW50X21lc3NhZ2UYAiABKAsyGy5hZ2VudC52MS5F" + + "eGVjQ2xpZW50TWVzc2FnZUgAEkkKG2V4ZWNfY2xpZW50X2NvbnRyb2xfbWVzc2FnZRgFIAEoCzIi" + + "LmFnZW50LnYxLkV4ZWNDbGllbnRDb250cm9sTWVzc2FnZUgAEjYKEWt2X2NsaWVudF9tZXNzYWdl" + + "GAMgASgLMhkuYWdlbnQudjEuS3ZDbGllbnRNZXNzYWdlSAASOwoTY29udmVyc2F0aW9uX2FjdGlv" + + "bhgEIAEoCzIcLmFnZW50LnYxLkNvbnZlcnNhdGlvbkFjdGlvbkgAEj0KFGludGVyYWN0aW9uX3Jl" + + "c3BvbnNlGAYgASgLMh0uYWdlbnQudjEuSW50ZXJhY3Rpb25SZXNwb25zZUgAEjUKEGNsaWVudF9o" + + "ZWFydGJlYXQYByABKAsyGS5hZ2VudC52MS5DbGllbnRIZWFydGJlYXRIABIzCg9wcmV3YXJtX3Jl" + + "cXVlc3QYCCABKAsyGC5hZ2VudC52MS5QcmV3YXJtUmVxdWVzdEgAQgkKB21lc3NhZ2UiogMKEkFn" + + "ZW50U2VydmVyTWVzc2FnZRI5ChJpbnRlcmFjdGlvbl91cGRhdGUYASABKAsyGy5hZ2VudC52MS5J" + + "bnRlcmFjdGlvblVwZGF0ZUgAEjoKE2V4ZWNfc2VydmVyX21lc3NhZ2UYAiABKAsyGy5hZ2VudC52" + + "MS5FeGVjU2VydmVyTWVzc2FnZUgAEkkKG2V4ZWNfc2VydmVyX2NvbnRyb2xfbWVzc2FnZRgFIAEo" + + "CzIiLmFnZW50LnYxLkV4ZWNTZXJ2ZXJDb250cm9sTWVzc2FnZUgAEk4KHmNvbnZlcnNhdGlvbl9j" + + "aGVja3BvaW50X3VwZGF0ZRgDIAEoCzIkLmFnZW50LnYxLkNvbnZlcnNhdGlvblN0YXRlU3RydWN0" + + "dXJlSAASNgoRa3Zfc2VydmVyX21lc3NhZ2UYBCABKAsyGS5hZ2VudC52MS5LdlNlcnZlck1lc3Nh" + + "Z2VIABI3ChFpbnRlcmFjdGlvbl9xdWVyeRgHIAEoCzIaLmFnZW50LnYxLkludGVyYWN0aW9uUXVl" + + "cnlIAEIJCgdtZXNzYWdlIigKEE5hbWVBZ2VudFJlcXVlc3QSFAoMdXNlcl9tZXNzYWdlGAEgASgJ" + + "IiEKEU5hbWVBZ2VudFJlc3BvbnNlEgwKBG5hbWUYASABKAkiMgoWR2V0VXNhYmxlTW9kZWxzUmVx" + + "dWVzdBIYChBjdXN0b21fbW9kZWxfaWRzGAEgAygJIkEKF0dldFVzYWJsZU1vZGVsc1Jlc3BvbnNl" + + "EiYKBm1vZGVscxgBIAMoCzIWLmFnZW50LnYxLk1vZGVsRGV0YWlscyIeChxHZXREZWZhdWx0TW9k" + + "ZWxGb3JDbGlSZXF1ZXN0IkYKHUdldERlZmF1bHRNb2RlbEZvckNsaVJlc3BvbnNlEiUKBW1vZGVs" + + "GAEgASgLMhYuYWdlbnQudjEuTW9kZWxEZXRhaWxzIh8KHUdldEFsbG93ZWRNb2RlbEludGVudHNS" + + "ZXF1ZXN0IjcKHkdldEFsbG93ZWRNb2RlbEludGVudHNSZXNwb25zZRIVCg1tb2RlbF9pbnRlbnRz" + + "GAEgAygJIpcCChNJZGVFZGl0b3JzU3RhdGVGaWxlEhUKDXJlbGF0aXZlX3BhdGgYASABKAkSFQoN" + + "YWJzb2x1dGVfcGF0aBgCIAEoCRIhChRpc19jdXJyZW50bHlfZm9jdXNlZBgDIAEoCEgAiAEBEiAK" + + "E2N1cnJlbnRfbGluZV9udW1iZXIYBCABKAVIAYgBARIeChFjdXJyZW50X2xpbmVfdGV4dBgFIAEo" + + "CUgCiAEBEhcKCmxpbmVfY291bnQYBiABKAVIA4gBAUIXChVfaXNfY3VycmVudGx5X2ZvY3VzZWRC" + + "FgoUX2N1cnJlbnRfbGluZV9udW1iZXJCFAoSX2N1cnJlbnRfbGluZV90ZXh0Qg0KC19saW5lX2Nv" + + "dW50IlMKE0lkZUVkaXRvcnNTdGF0ZUxpdGUSPAoVcmVjZW50bHlfdmlld2VkX2ZpbGVzGAEgAygL" + + "Mh0uYWdlbnQudjEuSWRlRWRpdG9yc1N0YXRlRmlsZSJ0ChZBcHBseUFnZW50RGlmZlRvb2xDYWxs" + + "EioKBGFyZ3MYASABKAsyHC5hZ2VudC52MS5BcHBseUFnZW50RGlmZkFyZ3MSLgoGcmVzdWx0GAIg" + + "ASgLMh4uYWdlbnQudjEuQXBwbHlBZ2VudERpZmZSZXN1bHQiJgoSQXBwbHlBZ2VudERpZmZBcmdz" + + "EhAKCGFnZW50X2lkGAEgASgJIoQBChRBcHBseUFnZW50RGlmZlJlc3VsdBIyCgdzdWNjZXNzGAEg" + + "ASgLMh8uYWdlbnQudjEuQXBwbHlBZ2VudERpZmZTdWNjZXNzSAASLgoFZXJyb3IYAiABKAsyHS5h" + + "Z2VudC52MS5BcHBseUFnZW50RGlmZkVycm9ySABCCAoGcmVzdWx0Ik4KFUFwcGx5QWdlbnREaWZm" + + "U3VjY2VzcxI1Cg9hcHBsaWVkX2NoYW5nZXMYASADKAsyHC5hZ2VudC52MS5BcHBsaWVkQWdlbnRD" + + "aGFuZ2Ui6QEKEkFwcGxpZWRBZ2VudENoYW5nZRIMCgRwYXRoGAEgASgJEhMKC2NoYW5nZV90eXBl" + + "GAIgASgFEhsKDmJlZm9yZV9jb250ZW50GAMgASgJSACIAQESGgoNYWZ0ZXJfY29udGVudBgEIAEo" + + "CUgBiAEBEhIKBWVycm9yGAUgASgJSAKIAQESHgoRbWVzc2FnZV9mb3JfbW9kZWwYBiABKAlIA4gB" + + "AUIRCg9fYmVmb3JlX2NvbnRlbnRCEAoOX2FmdGVyX2NvbnRlbnRCCAoGX2Vycm9yQhQKEl9tZXNz" + + "YWdlX2Zvcl9tb2RlbCJbChNBcHBseUFnZW50RGlmZkVycm9yEg0KBWVycm9yGAEgASgJEjUKD2Fw" + + "cGxpZWRfY2hhbmdlcxgCIAMoCzIcLmFnZW50LnYxLkFwcGxpZWRBZ2VudENoYW5nZSJrChNBc2tR" + + "dWVzdGlvblRvb2xDYWxsEicKBGFyZ3MYASABKAsyGS5hZ2VudC52MS5Bc2tRdWVzdGlvbkFyZ3MS" + + "KwoGcmVzdWx0GAIgASgLMhsuYWdlbnQudjEuQXNrUXVlc3Rpb25SZXN1bHQijwEKD0Fza1F1ZXN0" + + "aW9uQXJncxINCgV0aXRsZRgBIAEoCRI1CglxdWVzdGlvbnMYAiADKAsyIi5hZ2VudC52MS5Bc2tR" + + "dWVzdGlvbkFyZ3NfUXVlc3Rpb24SEQoJcnVuX2FzeW5jGAUgASgIEiMKG2FzeW5jX29yaWdpbmFs" + + "X3Rvb2xfY2FsbF9pZBgGIAEoCSKBAQoYQXNrUXVlc3Rpb25BcmdzX1F1ZXN0aW9uEgoKAmlkGAEg" + + "ASgJEg4KBnByb21wdBgCIAEoCRIxCgdvcHRpb25zGAMgAygLMiAuYWdlbnQudjEuQXNrUXVlc3Rp" + + "b25BcmdzX09wdGlvbhIWCg5hbGxvd19tdWx0aXBsZRgEIAEoCCIzChZBc2tRdWVzdGlvbkFyZ3Nf" + + "T3B0aW9uEgoKAmlkGAEgASgJEg0KBWxhYmVsGAIgASgJIhIKEEFza1F1ZXN0aW9uQXN5bmMi2wEK" + + "EUFza1F1ZXN0aW9uUmVzdWx0Ei8KB3N1Y2Nlc3MYASABKAsyHC5hZ2VudC52MS5Bc2tRdWVzdGlv" + + "blN1Y2Nlc3NIABIrCgVlcnJvchgCIAEoCzIaLmFnZW50LnYxLkFza1F1ZXN0aW9uRXJyb3JIABIx" + + "CghyZWplY3RlZBgDIAEoCzIdLmFnZW50LnYxLkFza1F1ZXN0aW9uUmVqZWN0ZWRIABIrCgVhc3lu" + + "YxgEIAEoCzIaLmFnZW50LnYxLkFza1F1ZXN0aW9uQXN5bmNIAEIICgZyZXN1bHQiSgoSQXNrUXVl" + + "c3Rpb25TdWNjZXNzEjQKB2Fuc3dlcnMYASADKAsyIy5hZ2VudC52MS5Bc2tRdWVzdGlvblN1Y2Nl" + + "c3NfQW5zd2VyIk0KGUFza1F1ZXN0aW9uU3VjY2Vzc19BbnN3ZXISEwoLcXVlc3Rpb25faWQYASAB" + + "KAkSGwoTc2VsZWN0ZWRfb3B0aW9uX2lkcxgCIAMoCSIpChBBc2tRdWVzdGlvbkVycm9yEhUKDWVy" + + "cm9yX21lc3NhZ2UYASABKAkiJQoTQXNrUXVlc3Rpb25SZWplY3RlZBIOCgZyZWFzb24YASABKAki" + + "iQIKGEJhY2tncm91bmRTaGVsbFNwYXduQXJncxIPCgdjb21tYW5kGAEgASgJEhkKEXdvcmtpbmdf" + + "ZGlyZWN0b3J5GAIgASgJEhQKDHRvb2xfY2FsbF9pZBgDIAEoCRI7Cg5wYXJzaW5nX3Jlc3VsdBgE" + + "IAEoCzIjLmFnZW50LnYxLlNoZWxsQ29tbWFuZFBhcnNpbmdSZXN1bHQSNAoOc2FuZGJveF9wb2xp" + + "Y3kYBSABKAsyFy5hZ2VudC52MS5TYW5kYm94UG9saWN5SACIAQESJQodZW5hYmxlX3dyaXRlX3No" + + "ZWxsX3N0ZGluX3Rvb2wYBiABKAhCEQoPX3NhbmRib3hfcG9saWN5IoECChpCYWNrZ3JvdW5kU2hl" + + "bGxTcGF3blJlc3VsdBI4CgdzdWNjZXNzGAEgASgLMiUuYWdlbnQudjEuQmFja2dyb3VuZFNoZWxs" + + "U3Bhd25TdWNjZXNzSAASNAoFZXJyb3IYAiABKAsyIy5hZ2VudC52MS5CYWNrZ3JvdW5kU2hlbGxT" + + "cGF3bkVycm9ySAASKwoIcmVqZWN0ZWQYAyABKAsyFy5hZ2VudC52MS5TaGVsbFJlamVjdGVkSAAS" + + "PAoRcGVybWlzc2lvbl9kZW5pZWQYBCABKAsyHy5hZ2VudC52MS5TaGVsbFBlcm1pc3Npb25EZW5p" + + "ZWRIAEIICgZyZXN1bHQidQobQmFja2dyb3VuZFNoZWxsU3Bhd25TdWNjZXNzEhAKCHNoZWxsX2lk" + + "GAEgASgNEg8KB2NvbW1hbmQYAiABKAkSGQoRd29ya2luZ19kaXJlY3RvcnkYAyABKAkSEAoDcGlk" + + "GAQgASgNSACIAQFCBgoEX3BpZCJWChlCYWNrZ3JvdW5kU2hlbGxTcGF3bkVycm9yEg8KB2NvbW1h" + + "bmQYASABKAkSGQoRd29ya2luZ19kaXJlY3RvcnkYAiABKAkSDQoFZXJyb3IYAyABKAkiNgoTV3Jp" + + "dGVTaGVsbFN0ZGluQXJncxIQCghzaGVsbF9pZBgBIAEoDRINCgVjaGFycxgCIAEoCSKHAQoVV3Jp" + + "dGVTaGVsbFN0ZGluUmVzdWx0EjMKB3N1Y2Nlc3MYASABKAsyIC5hZ2VudC52MS5Xcml0ZVNoZWxs" + + "U3RkaW5TdWNjZXNzSAASLwoFZXJyb3IYAiABKAsyHi5hZ2VudC52MS5Xcml0ZVNoZWxsU3RkaW5F" + + "cnJvckgAQggKBnJlc3VsdCJdChZXcml0ZVNoZWxsU3RkaW5TdWNjZXNzEhAKCHNoZWxsX2lkGAEg" + + "ASgNEjEKKXRlcm1pbmFsX2ZpbGVfbGVuZ3RoX2JlZm9yZV9pbnB1dF93cml0dGVuGAIgASgNIiUK" + + "FFdyaXRlU2hlbGxTdGRpbkVycm9yEg0KBWVycm9yGAEgASgJIiIKCkNvb3JkaW5hdGUSCQoBeBgB" + + "IAEoBRIJCgF5GAIgASgFIlUKD0NvbXB1dGVyVXNlQXJncxIUCgx0b29sX2NhbGxfaWQYASABKAkS" + + "LAoHYWN0aW9ucxgCIAMoCzIbLmFnZW50LnYxLkNvbXB1dGVyVXNlQWN0aW9uIoEEChFDb21wdXRl" + + "clVzZUFjdGlvbhIvCgptb3VzZV9tb3ZlGAEgASgLMhkuYWdlbnQudjEuTW91c2VNb3ZlQWN0aW9u" + + "SAASJgoFY2xpY2sYAiABKAsyFS5hZ2VudC52MS5DbGlja0FjdGlvbkgAEi8KCm1vdXNlX2Rvd24Y" + + "AyABKAsyGS5hZ2VudC52MS5Nb3VzZURvd25BY3Rpb25IABIrCghtb3VzZV91cBgEIAEoCzIXLmFn" + + "ZW50LnYxLk1vdXNlVXBBY3Rpb25IABIkCgRkcmFnGAUgASgLMhQuYWdlbnQudjEuRHJhZ0FjdGlv" + + "bkgAEigKBnNjcm9sbBgGIAEoCzIWLmFnZW50LnYxLlNjcm9sbEFjdGlvbkgAEiQKBHR5cGUYByAB" + + "KAsyFC5hZ2VudC52MS5UeXBlQWN0aW9uSAASIgoDa2V5GAggASgLMhMuYWdlbnQudjEuS2V5QWN0" + + "aW9uSAASJAoEd2FpdBgJIAEoCzIULmFnZW50LnYxLldhaXRBY3Rpb25IABIwCgpzY3JlZW5zaG90" + + "GAogASgLMhouYWdlbnQudjEuU2NyZWVuc2hvdEFjdGlvbkgAEjkKD2N1cnNvcl9wb3NpdGlvbhgL" + + "IAEoCzIeLmFnZW50LnYxLkN1cnNvclBvc2l0aW9uQWN0aW9uSABCCAoGYWN0aW9uIjsKD01vdXNl" + + "TW92ZUFjdGlvbhIoCgpjb29yZGluYXRlGAEgASgLMhQuYWdlbnQudjEuQ29vcmRpbmF0ZSKYAQoL" + + "Q2xpY2tBY3Rpb24SLQoKY29vcmRpbmF0ZRgBIAEoCzIULmFnZW50LnYxLkNvb3JkaW5hdGVIAIgB" + + "ARIOCgZidXR0b24YAiABKAUSDQoFY291bnQYAyABKAUSGgoNbW9kaWZpZXJfa2V5cxgEIAEoCUgB" + + "iAEBQg0KC19jb29yZGluYXRlQhAKDl9tb2RpZmllcl9rZXlzIiEKD01vdXNlRG93bkFjdGlvbhIO" + + "CgZidXR0b24YASABKAUiHwoNTW91c2VVcEFjdGlvbhIOCgZidXR0b24YASABKAUiQAoKRHJhZ0Fj" + + "dGlvbhIiCgRwYXRoGAEgAygLMhQuYWdlbnQudjEuQ29vcmRpbmF0ZRIOCgZidXR0b24YAiABKAUi" + + "nQEKDFNjcm9sbEFjdGlvbhItCgpjb29yZGluYXRlGAEgASgLMhQuYWdlbnQudjEuQ29vcmRpbmF0" + + "ZUgAiAEBEhEKCWRpcmVjdGlvbhgCIAEoBRIOCgZhbW91bnQYAyABKAUSGgoNbW9kaWZpZXJfa2V5" + + "cxgEIAEoCUgBiAEBQg0KC19jb29yZGluYXRlQhAKDl9tb2RpZmllcl9rZXlzIhoKClR5cGVBY3Rp" + + "b24SDAoEdGV4dBgBIAEoCSJMCglLZXlBY3Rpb24SCwoDa2V5GAEgASgJEh0KEGhvbGRfZHVyYXRp" + + "b25fbXMYAiABKAVIAIgBAUITChFfaG9sZF9kdXJhdGlvbl9tcyIhCgpXYWl0QWN0aW9uEhMKC2R1" + + "cmF0aW9uX21zGAEgASgFIhIKEFNjcmVlbnNob3RBY3Rpb24iFgoUQ3Vyc29yUG9zaXRpb25BY3Rp" + + "b24iewoRQ29tcHV0ZXJVc2VSZXN1bHQSLwoHc3VjY2VzcxgBIAEoCzIcLmFnZW50LnYxLkNvbXB1" + + "dGVyVXNlU3VjY2Vzc0gAEisKBWVycm9yGAIgASgLMhouYWdlbnQudjEuQ29tcHV0ZXJVc2VFcnJv" + + "ckgAQggKBnJlc3VsdCL7AQoSQ29tcHV0ZXJVc2VTdWNjZXNzEhQKDGFjdGlvbl9jb3VudBgBIAEo" + + "BRITCgtkdXJhdGlvbl9tcxgCIAEoBRIXCgpzY3JlZW5zaG90GAMgASgJSACIAQESEAoDbG9nGAQg" + + "ASgJSAGIAQESHAoPc2NyZWVuc2hvdF9wYXRoGAUgASgJSAKIAQESMgoPY3Vyc29yX3Bvc2l0aW9u" + + "GAYgASgLMhQuYWdlbnQudjEuQ29vcmRpbmF0ZUgDiAEBQg0KC19zY3JlZW5zaG90QgYKBF9sb2dC" + + "EgoQX3NjcmVlbnNob3RfcGF0aEISChBfY3Vyc29yX3Bvc2l0aW9uIsABChBDb21wdXRlclVzZUVy" + + "cm9yEg0KBWVycm9yGAEgASgJEhQKDGFjdGlvbl9jb3VudBgCIAEoBRITCgtkdXJhdGlvbl9tcxgD" + + "IAEoBRIQCgNsb2cYBCABKAlIAIgBARIXCgpzY3JlZW5zaG90GAUgASgJSAGIAQESHAoPc2NyZWVu" + + "c2hvdF9wYXRoGAYgASgJSAKIAQFCBgoEX2xvZ0INCgtfc2NyZWVuc2hvdEISChBfc2NyZWVuc2hv" + + "dF9wYXRoImsKE0NvbXB1dGVyVXNlVG9vbENhbGwSJwoEYXJncxgBIAEoCzIZLmFnZW50LnYxLkNv" + + "bXB1dGVyVXNlQXJncxIrCgZyZXN1bHQYAiABKAsyGy5hZ2VudC52MS5Db21wdXRlclVzZVJlc3Vs" + + "dCJoChJDcmVhdGVQbGFuVG9vbENhbGwSJgoEYXJncxgBIAEoCzIYLmFnZW50LnYxLkNyZWF0ZVBs" + + "YW5BcmdzEioKBnJlc3VsdBgCIAEoCzIaLmFnZW50LnYxLkNyZWF0ZVBsYW5SZXN1bHQiOAoFUGhh" + + "c2USDAoEbmFtZRgBIAEoCRIhCgV0b2RvcxgCIAMoCzISLmFnZW50LnYxLlRvZG9JdGVtIpYBCg5D" + + "cmVhdGVQbGFuQXJncxIMCgRwbGFuGAEgASgJEiEKBXRvZG9zGAIgAygLMhIuYWdlbnQudjEuVG9k" + + "b0l0ZW0SEAoIb3ZlcnZpZXcYAyABKAkSDAoEbmFtZRgEIAEoCRISCgppc19wcm9qZWN0GAUgASgI" + + "Eh8KBnBoYXNlcxgGIAMoCzIPLmFnZW50LnYxLlBoYXNlIooBChBDcmVhdGVQbGFuUmVzdWx0EhAK" + + "CHBsYW5fdXJpGAMgASgJEi4KB3N1Y2Nlc3MYASABKAsyGy5hZ2VudC52MS5DcmVhdGVQbGFuU3Vj" + + "Y2Vzc0gAEioKBWVycm9yGAIgASgLMhkuYWdlbnQudjEuQ3JlYXRlUGxhbkVycm9ySABCCAoGcmVz" + + "dWx0IhMKEUNyZWF0ZVBsYW5TdWNjZXNzIiAKD0NyZWF0ZVBsYW5FcnJvchINCgVlcnJvchgBIAEo" + + "CSJWChZDcmVhdGVQbGFuUmVxdWVzdFF1ZXJ5EiYKBGFyZ3MYASABKAsyGC5hZ2VudC52MS5DcmVh" + + "dGVQbGFuQXJncxIUCgx0b29sX2NhbGxfaWQYAiABKAkiRwoZQ3JlYXRlUGxhblJlcXVlc3RSZXNw" + + "b25zZRIqCgZyZXN1bHQYASABKAsyGi5hZ2VudC52MS5DcmVhdGVQbGFuUmVzdWx0IhYKFEN1cnNv" + + "clJ1bGVUeXBlR2xvYmFsIigKF0N1cnNvclJ1bGVUeXBlRmlsZUdsb2JzEg0KBWdsb2JzGAEgAygJ" + + "IjEKGkN1cnNvclJ1bGVUeXBlQWdlbnRGZXRjaGVkEhMKC2Rlc2NyaXB0aW9uGAEgASgJIiAKHkN1" + + "cnNvclJ1bGVUeXBlTWFudWFsbHlBdHRhY2hlZCKLAgoOQ3Vyc29yUnVsZVR5cGUSMAoGZ2xvYmFs" + + "GAEgASgLMh4uYWdlbnQudjEuQ3Vyc29yUnVsZVR5cGVHbG9iYWxIABI5CgxmaWxlX2dsb2JiZWQY" + + "AiABKAsyIS5hZ2VudC52MS5DdXJzb3JSdWxlVHlwZUZpbGVHbG9ic0gAEj0KDWFnZW50X2ZldGNo" + + "ZWQYAyABKAsyJC5hZ2VudC52MS5DdXJzb3JSdWxlVHlwZUFnZW50RmV0Y2hlZEgAEkUKEW1hbnVh" + + "bGx5X2F0dGFjaGVkGAQgASgLMiguYWdlbnQudjEuQ3Vyc29yUnVsZVR5cGVNYW51YWxseUF0dGFj" + + "aGVkSABCBgoEdHlwZSLIAQoKQ3Vyc29yUnVsZRIRCglmdWxsX3BhdGgYASABKAkSDwoHY29udGVu" + + "dBgCIAEoCRImCgR0eXBlGAMgASgLMhguYWdlbnQudjEuQ3Vyc29yUnVsZVR5cGUSDgoGc291cmNl" + + "GAQgASgFEh4KEWdpdF9yZW1vdGVfb3JpZ2luGAUgASgJSACIAQESGAoLcGFyc2VfZXJyb3IYBiAB" + + "KAlIAYgBAUIUChJfZ2l0X3JlbW90ZV9vcmlnaW5CDgoMX3BhcnNlX2Vycm9yIjAKCkRlbGV0ZUFy" + + "Z3MSDAoEcGF0aBgBIAEoCRIUCgx0b29sX2NhbGxfaWQYAiABKAki7QIKDERlbGV0ZVJlc3VsdBIq" + + "CgdzdWNjZXNzGAEgASgLMhcuYWdlbnQudjEuRGVsZXRlU3VjY2Vzc0gAEjYKDmZpbGVfbm90X2Zv" + + "dW5kGAIgASgLMhwuYWdlbnQudjEuRGVsZXRlRmlsZU5vdEZvdW5kSAASKwoIbm90X2ZpbGUYAyAB" + + "KAsyFy5hZ2VudC52MS5EZWxldGVOb3RGaWxlSAASPQoRcGVybWlzc2lvbl9kZW5pZWQYBCABKAsy" + + "IC5hZ2VudC52MS5EZWxldGVQZXJtaXNzaW9uRGVuaWVkSAASLQoJZmlsZV9idXN5GAUgASgLMhgu" + + "YWdlbnQudjEuRGVsZXRlRmlsZUJ1c3lIABIsCghyZWplY3RlZBgGIAEoCzIYLmFnZW50LnYxLkRl" + + "bGV0ZVJlamVjdGVkSAASJgoFZXJyb3IYByABKAsyFS5hZ2VudC52MS5EZWxldGVFcnJvckgAQggK" + + "BnJlc3VsdCJcCg1EZWxldGVTdWNjZXNzEgwKBHBhdGgYASABKAkSFAoMZGVsZXRlZF9maWxlGAIg" + + "ASgJEhEKCWZpbGVfc2l6ZRgDIAEoAxIUCgxwcmV2X2NvbnRlbnQYBCABKAkiIgoSRGVsZXRlRmls" + + "ZU5vdEZvdW5kEgwKBHBhdGgYASABKAkiMgoNRGVsZXRlTm90RmlsZRIMCgRwYXRoGAEgASgJEhMK" + + "C2FjdHVhbF90eXBlGAIgASgJIlkKFkRlbGV0ZVBlcm1pc3Npb25EZW5pZWQSDAoEcGF0aBgBIAEo" + + "CRIcChRjbGllbnRfdmlzaWJsZV9lcnJvchgCIAEoCRITCgtpc19yZWFkb25seRgDIAEoCCIeCg5E" + + "ZWxldGVGaWxlQnVzeRIMCgRwYXRoGAEgASgJIi4KDkRlbGV0ZVJlamVjdGVkEgwKBHBhdGgYASAB" + + "KAkSDgoGcmVhc29uGAIgASgJIioKC0RlbGV0ZUVycm9yEgwKBHBhdGgYASABKAkSDQoFZXJyb3IY" + + "AiABKAkiXAoORGVsZXRlVG9vbENhbGwSIgoEYXJncxgBIAEoCzIULmFnZW50LnYxLkRlbGV0ZUFy" + + "Z3MSJgoGcmVzdWx0GAIgASgLMhYuYWdlbnQudjEuRGVsZXRlUmVzdWx0IjUKD0RpYWdub3N0aWNz" + + "QXJncxIMCgRwYXRoGAEgASgJEhQKDHRvb2xfY2FsbF9pZBgCIAEoCSKvAgoRRGlhZ25vc3RpY3NS" + + "ZXN1bHQSLwoHc3VjY2VzcxgBIAEoCzIcLmFnZW50LnYxLkRpYWdub3N0aWNzU3VjY2Vzc0gAEisK" + + "BWVycm9yGAIgASgLMhouYWdlbnQudjEuRGlhZ25vc3RpY3NFcnJvckgAEjEKCHJlamVjdGVkGAMg" + + "ASgLMh0uYWdlbnQudjEuRGlhZ25vc3RpY3NSZWplY3RlZEgAEjsKDmZpbGVfbm90X2ZvdW5kGAQg" + + "ASgLMiEuYWdlbnQudjEuRGlhZ25vc3RpY3NGaWxlTm90Rm91bmRIABJCChFwZXJtaXNzaW9uX2Rl" + + "bmllZBgFIAEoCzIlLmFnZW50LnYxLkRpYWdub3N0aWNzUGVybWlzc2lvbkRlbmllZEgAQggKBnJl" + + "c3VsdCJoChJEaWFnbm9zdGljc1N1Y2Nlc3MSDAoEcGF0aBgBIAEoCRIpCgtkaWFnbm9zdGljcxgC" + + "IAMoCzIULmFnZW50LnYxLkRpYWdub3N0aWMSGQoRdG90YWxfZGlhZ25vc3RpY3MYAyABKAUifwoK" + + "RGlhZ25vc3RpYxIQCghzZXZlcml0eRgBIAEoBRIeCgVyYW5nZRgCIAEoCzIPLmFnZW50LnYxLlJh" + + "bmdlEg8KB21lc3NhZ2UYAyABKAkSDgoGc291cmNlGAQgASgJEgwKBGNvZGUYBSABKAkSEAoIaXNf" + + "c3RhbGUYBiABKAgiLwoQRGlhZ25vc3RpY3NFcnJvchIMCgRwYXRoGAEgASgJEg0KBWVycm9yGAIg" + + "ASgJIjMKE0RpYWdub3N0aWNzUmVqZWN0ZWQSDAoEcGF0aBgBIAEoCRIOCgZyZWFzb24YAiABKAki" + + "JwoXRGlhZ25vc3RpY3NGaWxlTm90Rm91bmQSDAoEcGF0aBgBIAEoCSIrChtEaWFnbm9zdGljc1Bl" + + "cm1pc3Npb25EZW5pZWQSDAoEcGF0aBgBIAEoCSJICghFZGl0QXJncxIMCgRwYXRoGAEgASgJEhsK" + + "DnN0cmVhbV9jb250ZW50GAYgASgJSACIAQFCEQoPX3N0cmVhbV9jb250ZW50ItYCCgpFZGl0UmVz" + + "dWx0EigKB3N1Y2Nlc3MYASABKAsyFS5hZ2VudC52MS5FZGl0U3VjY2Vzc0gAEjQKDmZpbGVfbm90" + + "X2ZvdW5kGAIgASgLMhouYWdlbnQudjEuRWRpdEZpbGVOb3RGb3VuZEgAEkQKFnJlYWRfcGVybWlz" + + "c2lvbl9kZW5pZWQYAyABKAsyIi5hZ2VudC52MS5FZGl0UmVhZFBlcm1pc3Npb25EZW5pZWRIABJG" + + "Chd3cml0ZV9wZXJtaXNzaW9uX2RlbmllZBgEIAEoCzIjLmFnZW50LnYxLkVkaXRXcml0ZVBlcm1p" + + "c3Npb25EZW5pZWRIABIqCghyZWplY3RlZBgGIAEoCzIWLmFnZW50LnYxLkVkaXRSZWplY3RlZEgA" + + "EiQKBWVycm9yGAcgASgLMhMuYWdlbnQudjEuRWRpdEVycm9ySABCCAoGcmVzdWx0IqQCCgtFZGl0" + + "U3VjY2VzcxIMCgRwYXRoGAEgASgJEhgKC2xpbmVzX2FkZGVkGAMgASgFSACIAQESGgoNbGluZXNf" + + "cmVtb3ZlZBgEIAEoBUgBiAEBEhgKC2RpZmZfc3RyaW5nGAUgASgJSAKIAQESJQoYYmVmb3JlX2Z1" + + "bGxfZmlsZV9jb250ZW50GAYgASgJSAOIAQESHwoXYWZ0ZXJfZnVsbF9maWxlX2NvbnRlbnQYByAB" + + "KAkSFAoHbWVzc2FnZRgIIAEoCUgEiAEBQg4KDF9saW5lc19hZGRlZEIQCg5fbGluZXNfcmVtb3Zl" + + "ZEIOCgxfZGlmZl9zdHJpbmdCGwoZX2JlZm9yZV9mdWxsX2ZpbGVfY29udGVudEIKCghfbWVzc2Fn" + + "ZSIgChBFZGl0RmlsZU5vdEZvdW5kEgwKBHBhdGgYASABKAkiKAoYRWRpdFJlYWRQZXJtaXNzaW9u" + + "RGVuaWVkEgwKBHBhdGgYASABKAkiTQoZRWRpdFdyaXRlUGVybWlzc2lvbkRlbmllZBIMCgRwYXRo" + + "GAEgASgJEg0KBWVycm9yGAIgASgJEhMKC2lzX3JlYWRvbmx5GAMgASgIIiwKDEVkaXRSZWplY3Rl" + + "ZBIMCgRwYXRoGAEgASgJEg4KBnJlYXNvbhgCIAEoCSJiCglFZGl0RXJyb3ISDAoEcGF0aBgBIAEo" + + "CRINCgVlcnJvchgCIAEoCRIgChNtb2RlbF92aXNpYmxlX2Vycm9yGAUgASgJSACIAQFCFgoUX21v" + + "ZGVsX3Zpc2libGVfZXJyb3IiVgoMRWRpdFRvb2xDYWxsEiAKBGFyZ3MYASABKAsyEi5hZ2VudC52" + + "MS5FZGl0QXJncxIkCgZyZXN1bHQYAiABKAsyFC5hZ2VudC52MS5FZGl0UmVzdWx0IjEKEUVkaXRU" + + "b29sQ2FsbERlbHRhEhwKFHN0cmVhbV9jb250ZW50X2RlbHRhGAEgASgJIjEKDEV4YUZldGNoQXJn" + + "cxILCgNpZHMYASADKAkSFAoMdG9vbF9jYWxsX2lkGAIgASgJIqIBCg5FeGFGZXRjaFJlc3VsdBIs" + + "CgdzdWNjZXNzGAEgASgLMhkuYWdlbnQudjEuRXhhRmV0Y2hTdWNjZXNzSAASKAoFZXJyb3IYAiAB" + + "KAsyFy5hZ2VudC52MS5FeGFGZXRjaEVycm9ySAASLgoIcmVqZWN0ZWQYAyABKAsyGi5hZ2VudC52" + + "MS5FeGFGZXRjaFJlamVjdGVkSABCCAoGcmVzdWx0Ij4KD0V4YUZldGNoU3VjY2VzcxIrCghjb250" + + "ZW50cxgBIAMoCzIZLmFnZW50LnYxLkV4YUZldGNoQ29udGVudCIeCg1FeGFGZXRjaEVycm9yEg0K" + + "BWVycm9yGAEgASgJIiIKEEV4YUZldGNoUmVqZWN0ZWQSDgoGcmVhc29uGAEgASgJIlMKD0V4YUZl" + + "dGNoQ29udGVudBINCgV0aXRsZRgBIAEoCRILCgN1cmwYAiABKAkSDAoEdGV4dBgDIAEoCRIWCg5w" + + "dWJsaXNoZWRfZGF0ZRgEIAEoCSJiChBFeGFGZXRjaFRvb2xDYWxsEiQKBGFyZ3MYASABKAsyFi5h" + + "Z2VudC52MS5FeGFGZXRjaEFyZ3MSKAoGcmVzdWx0GAIgASgLMhguYWdlbnQudjEuRXhhRmV0Y2hS" + + "ZXN1bHQiPAoURXhhRmV0Y2hSZXF1ZXN0UXVlcnkSJAoEYXJncxgBIAEoCzIWLmFnZW50LnYxLkV4" + + "YUZldGNoQXJncyKjAQoXRXhhRmV0Y2hSZXF1ZXN0UmVzcG9uc2USPgoIYXBwcm92ZWQYASABKAsy" + + "Ki5hZ2VudC52MS5FeGFGZXRjaFJlcXVlc3RSZXNwb25zZV9BcHByb3ZlZEgAEj4KCHJlamVjdGVk" + + "GAIgASgLMiouYWdlbnQudjEuRXhhRmV0Y2hSZXF1ZXN0UmVzcG9uc2VfUmVqZWN0ZWRIAEIICgZy" + + "ZXN1bHQiIgogRXhhRmV0Y2hSZXF1ZXN0UmVzcG9uc2VfQXBwcm92ZWQiMgogRXhhRmV0Y2hSZXF1" + + "ZXN0UmVzcG9uc2VfUmVqZWN0ZWQSDgoGcmVhc29uGAEgASgJIlcKDUV4YVNlYXJjaEFyZ3MSDQoF" + + "cXVlcnkYASABKAkSDAoEdHlwZRgCIAEoCRITCgtudW1fcmVzdWx0cxgDIAEoBRIUCgx0b29sX2Nh" + + "bGxfaWQYBCABKAkipgEKD0V4YVNlYXJjaFJlc3VsdBItCgdzdWNjZXNzGAEgASgLMhouYWdlbnQu" + + "djEuRXhhU2VhcmNoU3VjY2Vzc0gAEikKBWVycm9yGAIgASgLMhguYWdlbnQudjEuRXhhU2VhcmNo" + + "RXJyb3JIABIvCghyZWplY3RlZBgDIAEoCzIbLmFnZW50LnYxLkV4YVNlYXJjaFJlamVjdGVkSABC" + + "CAoGcmVzdWx0IkQKEEV4YVNlYXJjaFN1Y2Nlc3MSMAoKcmVmZXJlbmNlcxgBIAMoCzIcLmFnZW50" + + "LnYxLkV4YVNlYXJjaFJlZmVyZW5jZSIfCg5FeGFTZWFyY2hFcnJvchINCgVlcnJvchgBIAEoCSIj" + + "ChFFeGFTZWFyY2hSZWplY3RlZBIOCgZyZWFzb24YASABKAkiVgoSRXhhU2VhcmNoUmVmZXJlbmNl" + + "Eg0KBXRpdGxlGAEgASgJEgsKA3VybBgCIAEoCRIMCgR0ZXh0GAMgASgJEhYKDnB1Ymxpc2hlZF9k" + + "YXRlGAQgASgJImUKEUV4YVNlYXJjaFRvb2xDYWxsEiUKBGFyZ3MYASABKAsyFy5hZ2VudC52MS5F" + + "eGFTZWFyY2hBcmdzEikKBnJlc3VsdBgCIAEoCzIZLmFnZW50LnYxLkV4YVNlYXJjaFJlc3VsdCI+" + + "ChVFeGFTZWFyY2hSZXF1ZXN0UXVlcnkSJQoEYXJncxgBIAEoCzIXLmFnZW50LnYxLkV4YVNlYXJj" + + "aEFyZ3MipgEKGEV4YVNlYXJjaFJlcXVlc3RSZXNwb25zZRI/CghhcHByb3ZlZBgBIAEoCzIrLmFn" + + "ZW50LnYxLkV4YVNlYXJjaFJlcXVlc3RSZXNwb25zZV9BcHByb3ZlZEgAEj8KCHJlamVjdGVkGAIg" + + "ASgLMisuYWdlbnQudjEuRXhhU2VhcmNoUmVxdWVzdFJlc3BvbnNlX1JlamVjdGVkSABCCAoGcmVz" + + "dWx0IiMKIUV4YVNlYXJjaFJlcXVlc3RSZXNwb25zZV9BcHByb3ZlZCIzCiFFeGFTZWFyY2hSZXF1" + + "ZXN0UmVzcG9uc2VfUmVqZWN0ZWQSDgoGcmVhc29uGAEgASgJIiMKFUV4ZWNDbGllbnRTdHJlYW1D" + + "bG9zZRIKCgJpZBgBIAEoDSJWCg9FeGVjQ2xpZW50VGhyb3cSCgoCaWQYASABKA0SDQoFZXJyb3IY" + + "AiABKAkSGAoLc3RhY2tfdHJhY2UYAyABKAlIAIgBAUIOCgxfc3RhY2tfdHJhY2UiIQoTRXhlY0Ns" + + "aWVudEhlYXJ0YmVhdBIKCgJpZBgBIAEoDSK+AQoYRXhlY0NsaWVudENvbnRyb2xNZXNzYWdlEjcK" + + "DHN0cmVhbV9jbG9zZRgBIAEoCzIfLmFnZW50LnYxLkV4ZWNDbGllbnRTdHJlYW1DbG9zZUgAEioK" + + "BXRocm93GAIgASgLMhkuYWdlbnQudjEuRXhlY0NsaWVudFRocm93SAASMgoJaGVhcnRiZWF0GAMg" + + "ASgLMh0uYWdlbnQudjEuRXhlY0NsaWVudEhlYXJ0YmVhdEgAQgkKB21lc3NhZ2UihAEKC1NwYW5D" + + "b250ZXh0EhAKCHRyYWNlX2lkGAEgASgJEg8KB3NwYW5faWQYAiABKAkSGAoLdHJhY2VfZmxhZ3MY" + + "AyABKA1IAIgBARIYCgt0cmFjZV9zdGF0ZRgEIAEoCUgBiAEBQg4KDF90cmFjZV9mbGFnc0IOCgxf" + + "dHJhY2Vfc3RhdGUiCwoJQWJvcnRBcmdzIg0KC0Fib3J0UmVzdWx0IoUIChFFeGVjU2VydmVyTWVz" + + "c2FnZRIKCgJpZBgBIAEoDRIPCgdleGVjX2lkGA8gASgJEjAKDHNwYW5fY29udGV4dBgTIAEoCzIV" + + "LmFnZW50LnYxLlNwYW5Db250ZXh0SAGIAQESKQoKc2hlbGxfYXJncxgCIAEoCzITLmFnZW50LnYx" + + "LlNoZWxsQXJnc0gAEikKCndyaXRlX2FyZ3MYAyABKAsyEy5hZ2VudC52MS5Xcml0ZUFyZ3NIABIr" + + "CgtkZWxldGVfYXJncxgEIAEoCzIULmFnZW50LnYxLkRlbGV0ZUFyZ3NIABInCglncmVwX2FyZ3MY" + + "BSABKAsyEi5hZ2VudC52MS5HcmVwQXJnc0gAEicKCXJlYWRfYXJncxgHIAEoCzISLmFnZW50LnYx" + + "LlJlYWRBcmdzSAASIwoHbHNfYXJncxgIIAEoCzIQLmFnZW50LnYxLkxzQXJnc0gAEjUKEGRpYWdu" + + "b3N0aWNzX2FyZ3MYCSABKAsyGS5hZ2VudC52MS5EaWFnbm9zdGljc0FyZ3NIABI8ChRyZXF1ZXN0" + + "X2NvbnRleHRfYXJncxgKIAEoCzIcLmFnZW50LnYxLlJlcXVlc3RDb250ZXh0QXJnc0gAEiUKCG1j" + + "cF9hcmdzGAsgASgLMhEuYWdlbnQudjEuTWNwQXJnc0gAEjAKEXNoZWxsX3N0cmVhbV9hcmdzGA4g" + + "ASgLMhMuYWdlbnQudjEuU2hlbGxBcmdzSAASSQobYmFja2dyb3VuZF9zaGVsbF9zcGF3bl9hcmdz" + + "GBAgASgLMiIuYWdlbnQudjEuQmFja2dyb3VuZFNoZWxsU3Bhd25BcmdzSAASSgocbGlzdF9tY3Bf" + + "cmVzb3VyY2VzX2V4ZWNfYXJncxgRIAEoCzIiLmFnZW50LnYxLkxpc3RNY3BSZXNvdXJjZXNFeGVj" + + "QXJnc0gAEkgKG3JlYWRfbWNwX3Jlc291cmNlX2V4ZWNfYXJncxgSIAEoCzIhLmFnZW50LnYxLlJl" + + "YWRNY3BSZXNvdXJjZUV4ZWNBcmdzSAASKQoKZmV0Y2hfYXJncxgUIAEoCzITLmFnZW50LnYxLkZl" + + "dGNoQXJnc0gAEjgKEnJlY29yZF9zY3JlZW5fYXJncxgVIAEoCzIaLmFnZW50LnYxLlJlY29yZFNj" + + "cmVlbkFyZ3NIABI2ChFjb21wdXRlcl91c2VfYXJncxgWIAEoCzIZLmFnZW50LnYxLkNvbXB1dGVy" + + "VXNlQXJnc0gAEj8KFndyaXRlX3NoZWxsX3N0ZGluX2FyZ3MYFyABKAsyHS5hZ2VudC52MS5Xcml0" + + "ZVNoZWxsU3RkaW5BcmdzSABCCQoHbWVzc2FnZUIPCg1fc3Bhbl9jb250ZXh0Iv8HChFFeGVjQ2xp" + + "ZW50TWVzc2FnZRIKCgJpZBgBIAEoDRIPCgdleGVjX2lkGA8gASgJEi0KDHNoZWxsX3Jlc3VsdBgC" + + "IAEoCzIVLmFnZW50LnYxLlNoZWxsUmVzdWx0SAASLQoMd3JpdGVfcmVzdWx0GAMgASgLMhUuYWdl" + + "bnQudjEuV3JpdGVSZXN1bHRIABIvCg1kZWxldGVfcmVzdWx0GAQgASgLMhYuYWdlbnQudjEuRGVs" + + "ZXRlUmVzdWx0SAASKwoLZ3JlcF9yZXN1bHQYBSABKAsyFC5hZ2VudC52MS5HcmVwUmVzdWx0SAAS" + + "KwoLcmVhZF9yZXN1bHQYByABKAsyFC5hZ2VudC52MS5SZWFkUmVzdWx0SAASJwoJbHNfcmVzdWx0" + + "GAggASgLMhIuYWdlbnQudjEuTHNSZXN1bHRIABI5ChJkaWFnbm9zdGljc19yZXN1bHQYCSABKAsy" + + "Gy5hZ2VudC52MS5EaWFnbm9zdGljc1Jlc3VsdEgAEkAKFnJlcXVlc3RfY29udGV4dF9yZXN1bHQY" + + "CiABKAsyHi5hZ2VudC52MS5SZXF1ZXN0Q29udGV4dFJlc3VsdEgAEikKCm1jcF9yZXN1bHQYCyAB" + + "KAsyEy5hZ2VudC52MS5NY3BSZXN1bHRIABItCgxzaGVsbF9zdHJlYW0YDiABKAsyFS5hZ2VudC52" + + "MS5TaGVsbFN0cmVhbUgAEk0KHWJhY2tncm91bmRfc2hlbGxfc3Bhd25fcmVzdWx0GBAgASgLMiQu" + + "YWdlbnQudjEuQmFja2dyb3VuZFNoZWxsU3Bhd25SZXN1bHRIABJOCh5saXN0X21jcF9yZXNvdXJj" + + "ZXNfZXhlY19yZXN1bHQYESABKAsyJC5hZ2VudC52MS5MaXN0TWNwUmVzb3VyY2VzRXhlY1Jlc3Vs" + + "dEgAEkwKHXJlYWRfbWNwX3Jlc291cmNlX2V4ZWNfcmVzdWx0GBIgASgLMiMuYWdlbnQudjEuUmVh" + + "ZE1jcFJlc291cmNlRXhlY1Jlc3VsdEgAEi0KDGZldGNoX3Jlc3VsdBgUIAEoCzIVLmFnZW50LnYx" + + "LkZldGNoUmVzdWx0SAASPAoUcmVjb3JkX3NjcmVlbl9yZXN1bHQYFSABKAsyHC5hZ2VudC52MS5S" + + "ZWNvcmRTY3JlZW5SZXN1bHRIABI6ChNjb21wdXRlcl91c2VfcmVzdWx0GBYgASgLMhsuYWdlbnQu" + + "djEuQ29tcHV0ZXJVc2VSZXN1bHRIABJDChh3cml0ZV9zaGVsbF9zdGRpbl9yZXN1bHQYFyABKAsy" + + "Hy5hZ2VudC52MS5Xcml0ZVNoZWxsU3RkaW5SZXN1bHRIAEIJCgdtZXNzYWdlIi4KCUZldGNoQXJn" + + "cxILCgN1cmwYASABKAkSFAoMdG9vbF9jYWxsX2lkGAIgASgJImkKC0ZldGNoUmVzdWx0EikKB3N1" + + "Y2Nlc3MYASABKAsyFi5hZ2VudC52MS5GZXRjaFN1Y2Nlc3NIABIlCgVlcnJvchgCIAEoCzIULmFn" + + "ZW50LnYxLkZldGNoRXJyb3JIAEIICgZyZXN1bHQiVwoMRmV0Y2hTdWNjZXNzEgsKA3VybBgBIAEo" + + "CRIPCgdjb250ZW50GAIgASgJEhMKC3N0YXR1c19jb2RlGAMgASgFEhQKDGNvbnRlbnRfdHlwZRgE" + + "IAEoCSIoCgpGZXRjaEVycm9yEgsKA3VybBgBIAEoCRINCgVlcnJvchgCIAEoCSJtChFHZW5lcmF0" + + "ZUltYWdlQXJncxITCgtkZXNjcmlwdGlvbhgBIAEoCRIWCglmaWxlX3BhdGgYAiABKAlIAIgBARId" + + "ChVyZWZlcmVuY2VfaW1hZ2VfcGF0aHMYBSADKAlCDAoKX2ZpbGVfcGF0aCKBAQoTR2VuZXJhdGVJ" + + "bWFnZVJlc3VsdBIxCgdzdWNjZXNzGAEgASgLMh4uYWdlbnQudjEuR2VuZXJhdGVJbWFnZVN1Y2Nl" + + "c3NIABItCgVlcnJvchgCIAEoCzIcLmFnZW50LnYxLkdlbmVyYXRlSW1hZ2VFcnJvckgAQggKBnJl" + + "c3VsdCI9ChRHZW5lcmF0ZUltYWdlU3VjY2VzcxIRCglmaWxlX3BhdGgYASABKAkSEgoKaW1hZ2Vf" + + "ZGF0YRgCIAEoCSIjChJHZW5lcmF0ZUltYWdlRXJyb3ISDQoFZXJyb3IYASABKAkicQoVR2VuZXJh" + + "dGVJbWFnZVRvb2xDYWxsEikKBGFyZ3MYASABKAsyGy5hZ2VudC52MS5HZW5lcmF0ZUltYWdlQXJn" + + "cxItCgZyZXN1bHQYAiABKAsyHS5hZ2VudC52MS5HZW5lcmF0ZUltYWdlUmVzdWx0IsYECghHcmVw" + + "QXJncxIPCgdwYXR0ZXJuGAEgASgJEhEKBHBhdGgYAiABKAlIAIgBARIRCgRnbG9iGAMgASgJSAGI" + + "AQESGAoLb3V0cHV0X21vZGUYBCABKAlIAogBARIbCg5jb250ZXh0X2JlZm9yZRgFIAEoBUgDiAEB" + + "EhoKDWNvbnRleHRfYWZ0ZXIYBiABKAVIBIgBARIUCgdjb250ZXh0GAcgASgFSAWIAQESHQoQY2Fz" + + "ZV9pbnNlbnNpdGl2ZRgIIAEoCEgGiAEBEhEKBHR5cGUYCSABKAlIB4gBARIXCgpoZWFkX2xpbWl0" + + "GAogASgFSAiIAQESFgoJbXVsdGlsaW5lGAsgASgISAmIAQESEQoEc29ydBgMIAEoCUgKiAEBEhsK" + + "DnNvcnRfYXNjZW5kaW5nGA0gASgISAuIAQESFAoMdG9vbF9jYWxsX2lkGA4gASgJEjQKDnNhbmRi" + + "b3hfcG9saWN5GA8gASgLMhcuYWdlbnQudjEuU2FuZGJveFBvbGljeUgMiAEBQgcKBV9wYXRoQgcK" + + "BV9nbG9iQg4KDF9vdXRwdXRfbW9kZUIRCg9fY29udGV4dF9iZWZvcmVCEAoOX2NvbnRleHRfYWZ0" + + "ZXJCCgoIX2NvbnRleHRCEwoRX2Nhc2VfaW5zZW5zaXRpdmVCBwoFX3R5cGVCDQoLX2hlYWRfbGlt" + + "aXRCDAoKX211bHRpbGluZUIHCgVfc29ydEIRCg9fc29ydF9hc2NlbmRpbmdCEQoPX3NhbmRib3hf" + + "cG9saWN5ImYKCkdyZXBSZXN1bHQSKAoHc3VjY2VzcxgBIAEoCzIVLmFnZW50LnYxLkdyZXBTdWNj" + + "ZXNzSAASJAoFZXJyb3IYAiABKAsyEy5hZ2VudC52MS5HcmVwRXJyb3JIAEIICgZyZXN1bHQiGgoJ" + + "R3JlcEVycm9yEg0KBWVycm9yGAEgASgJIrQCCgtHcmVwU3VjY2VzcxIPCgdwYXR0ZXJuGAEgASgJ" + + "EgwKBHBhdGgYAiABKAkSEwoLb3V0cHV0X21vZGUYAyABKAkSRgoRd29ya3NwYWNlX3Jlc3VsdHMY" + + "BCADKAsyKy5hZ2VudC52MS5HcmVwU3VjY2Vzcy5Xb3Jrc3BhY2VSZXN1bHRzRW50cnkSPAoUYWN0" + + "aXZlX2VkaXRvcl9yZXN1bHQYBSABKAsyGS5hZ2VudC52MS5HcmVwVW5pb25SZXN1bHRIAIgBARpS" + + "ChVXb3Jrc3BhY2VSZXN1bHRzRW50cnkSCwoDa2V5GAEgASgJEigKBXZhbHVlGAIgASgLMhkuYWdl" + + "bnQudjEuR3JlcFVuaW9uUmVzdWx0OgI4AUIXChVfYWN0aXZlX2VkaXRvcl9yZXN1bHQiowEKD0dy" + + "ZXBVbmlvblJlc3VsdBIqCgVjb3VudBgBIAEoCzIZLmFnZW50LnYxLkdyZXBDb3VudFJlc3VsdEgA" + + "EioKBWZpbGVzGAIgASgLMhkuYWdlbnQudjEuR3JlcEZpbGVzUmVzdWx0SAASLgoHY29udGVudBgD" + + "IAEoCzIbLmFnZW50LnYxLkdyZXBDb250ZW50UmVzdWx0SABCCAoGcmVzdWx0IpsBCg9HcmVwQ291" + + "bnRSZXN1bHQSJwoGY291bnRzGAEgAygLMhcuYWdlbnQudjEuR3JlcEZpbGVDb3VudBITCgt0b3Rh" + + "bF9maWxlcxgCIAEoBRIVCg10b3RhbF9tYXRjaGVzGAMgASgFEhgKEGNsaWVudF90cnVuY2F0ZWQY" + + "BCABKAgSGQoRcmlwZ3JlcF90cnVuY2F0ZWQYBSABKAgiLAoNR3JlcEZpbGVDb3VudBIMCgRmaWxl" + + "GAEgASgJEg0KBWNvdW50GAIgASgFImoKD0dyZXBGaWxlc1Jlc3VsdBINCgVmaWxlcxgBIAMoCRIT" + + "Cgt0b3RhbF9maWxlcxgCIAEoBRIYChBjbGllbnRfdHJ1bmNhdGVkGAMgASgIEhkKEXJpcGdyZXBf" + + "dHJ1bmNhdGVkGAQgASgIIqQBChFHcmVwQ29udGVudFJlc3VsdBIoCgdtYXRjaGVzGAEgAygLMhcu" + + "YWdlbnQudjEuR3JlcEZpbGVNYXRjaBITCgt0b3RhbF9saW5lcxgCIAEoBRIbChN0b3RhbF9tYXRj" + + "aGVkX2xpbmVzGAMgASgFEhgKEGNsaWVudF90cnVuY2F0ZWQYBCABKAgSGQoRcmlwZ3JlcF90cnVu" + + "Y2F0ZWQYBSABKAgiSgoNR3JlcEZpbGVNYXRjaBIMCgRmaWxlGAEgASgJEisKB21hdGNoZXMYAiAD" + + "KAsyGi5hZ2VudC52MS5HcmVwQ29udGVudE1hdGNoImwKEEdyZXBDb250ZW50TWF0Y2gSEwoLbGlu" + + "ZV9udW1iZXIYASABKAUSDwoHY29udGVudBgCIAEoCRIZChFjb250ZW50X3RydW5jYXRlZBgDIAEo" + + "CBIXCg9pc19jb250ZXh0X2xpbmUYBCABKAgiHQoKR3JlcFN0cmVhbRIPCgdwYXR0ZXJuGAEgASgJ" + + "IlYKDEdyZXBUb29sQ2FsbBIgCgRhcmdzGAEgASgLMhIuYWdlbnQudjEuR3JlcEFyZ3MSJAoGcmVz" + + "dWx0GAIgASgLMhQuYWdlbnQudjEuR3JlcFJlc3VsdCIeCgtHZXRCbG9iQXJncxIPCgdibG9iX2lk" + + "GAEgASgMIjUKDUdldEJsb2JSZXN1bHQSFgoJYmxvYl9kYXRhGAEgASgMSACIAQFCDAoKX2Jsb2Jf" + + "ZGF0YSIxCgtTZXRCbG9iQXJncxIPCgdibG9iX2lkGAEgASgMEhEKCWJsb2JfZGF0YRgCIAEoDCI+" + + "Cg1TZXRCbG9iUmVzdWx0EiMKBWVycm9yGAEgASgLMg8uYWdlbnQudjEuRXJyb3JIAIgBAUIICgZf" + + "ZXJyb3IiywEKD0t2U2VydmVyTWVzc2FnZRIKCgJpZBgBIAEoDRIwCgxzcGFuX2NvbnRleHQYBCAB" + + "KAsyFS5hZ2VudC52MS5TcGFuQ29udGV4dEgBiAEBEi4KDWdldF9ibG9iX2FyZ3MYAiABKAsyFS5h" + + "Z2VudC52MS5HZXRCbG9iQXJnc0gAEi4KDXNldF9ibG9iX2FyZ3MYAyABKAsyFS5hZ2VudC52MS5T" + + "ZXRCbG9iQXJnc0gAQgkKB21lc3NhZ2VCDwoNX3NwYW5fY29udGV4dCKQAQoPS3ZDbGllbnRNZXNz" + + "YWdlEgoKAmlkGAEgASgNEjIKD2dldF9ibG9iX3Jlc3VsdBgCIAEoCzIXLmFnZW50LnYxLkdldEJs" + + "b2JSZXN1bHRIABIyCg9zZXRfYmxvYl9yZXN1bHQYAyABKAsyFy5hZ2VudC52MS5TZXRCbG9iUmVz" + + "dWx0SABCCQoHbWVzc2FnZSKtAQoGTHNBcmdzEgwKBHBhdGgYASABKAkSDgoGaWdub3JlGAIgAygJ" + + "EhQKDHRvb2xfY2FsbF9pZBgDIAEoCRI0Cg5zYW5kYm94X3BvbGljeRgEIAEoCzIXLmFnZW50LnYx" + + "LlNhbmRib3hQb2xpY3lIAIgBARIXCgp0aW1lb3V0X21zGAUgASgNSAGIAQFCEQoPX3NhbmRib3hf" + + "cG9saWN5Qg0KC190aW1lb3V0X21zIrIBCghMc1Jlc3VsdBImCgdzdWNjZXNzGAEgASgLMhMuYWdl" + + "bnQudjEuTHNTdWNjZXNzSAASIgoFZXJyb3IYAiABKAsyES5hZ2VudC52MS5Mc0Vycm9ySAASKAoI" + + "cmVqZWN0ZWQYAyABKAsyFC5hZ2VudC52MS5Mc1JlamVjdGVkSAASJgoHdGltZW91dBgEIAEoCzIT" + + "LmFnZW50LnYxLkxzVGltZW91dEgAQggKBnJlc3VsdCJHCglMc1N1Y2Nlc3MSOgoTZGlyZWN0b3J5" + + "X3RyZWVfcm9vdBgBIAEoCzIdLmFnZW50LnYxLkxzRGlyZWN0b3J5VHJlZU5vZGUi9gIKE0xzRGly" + + "ZWN0b3J5VHJlZU5vZGUSEAoIYWJzX3BhdGgYASABKAkSNAoNY2hpbGRyZW5fZGlycxgCIAMoCzId" + + "LmFnZW50LnYxLkxzRGlyZWN0b3J5VHJlZU5vZGUSOgoOY2hpbGRyZW5fZmlsZXMYAyADKAsyIi5h" + + "Z2VudC52MS5Mc0RpcmVjdG9yeVRyZWVOb2RlX0ZpbGUSHwoXY2hpbGRyZW5fd2VyZV9wcm9jZXNz" + + "ZWQYBCABKAgSZAodZnVsbF9zdWJ0cmVlX2V4dGVuc2lvbl9jb3VudHMYBSADKAsyPS5hZ2VudC52" + + "MS5Mc0RpcmVjdG9yeVRyZWVOb2RlLkZ1bGxTdWJ0cmVlRXh0ZW5zaW9uQ291bnRzRW50cnkSEQoJ" + + "bnVtX2ZpbGVzGAYgASgFGkEKH0Z1bGxTdWJ0cmVlRXh0ZW5zaW9uQ291bnRzRW50cnkSCwoDa2V5" + + "GAEgASgJEg0KBXZhbHVlGAIgASgFOgI4ASJ6ChhMc0RpcmVjdG9yeVRyZWVOb2RlX0ZpbGUSDAoE" + + "bmFtZRgBIAEoCRI6ChF0ZXJtaW5hbF9tZXRhZGF0YRgCIAEoCzIaLmFnZW50LnYxLlRlcm1pbmFs" + + "TWV0YWRhdGFIAIgBAUIUChJfdGVybWluYWxfbWV0YWRhdGEiJgoHTHNFcnJvchIMCgRwYXRoGAEg" + + "ASgJEg0KBWVycm9yGAIgASgJIioKCkxzUmVqZWN0ZWQSDAoEcGF0aBgBIAEoCRIOCgZyZWFzb24Y" + + "AiABKAkiRwoJTHNUaW1lb3V0EjoKE2RpcmVjdG9yeV90cmVlX3Jvb3QYASABKAsyHS5hZ2VudC52" + + "MS5Mc0RpcmVjdG9yeVRyZWVOb2RlIvEBChBUZXJtaW5hbE1ldGFkYXRhEhAKA2N3ZBgBIAEoCUgA" + + "iAEBEjkKDWxhc3RfY29tbWFuZHMYAiADKAsyIi5hZ2VudC52MS5UZXJtaW5hbE1ldGFkYXRhX0Nv" + + "bW1hbmQSHQoQbGFzdF9tb2RpZmllZF9tcxgDIAEoA0gBiAEBEkAKD2N1cnJlbnRfY29tbWFuZBgE" + + "IAEoCzIiLmFnZW50LnYxLlRlcm1pbmFsTWV0YWRhdGFfQ29tbWFuZEgCiAEBQgYKBF9jd2RCEwoR" + + "X2xhc3RfbW9kaWZpZWRfbXNCEgoQX2N1cnJlbnRfY29tbWFuZCKnAQoYVGVybWluYWxNZXRhZGF0" + + "YV9Db21tYW5kEg8KB2NvbW1hbmQYASABKAkSFgoJZXhpdF9jb2RlGAIgASgFSACIAQESGQoMdGlt" + + "ZXN0YW1wX21zGAMgASgDSAGIAQESGAoLZHVyYXRpb25fbXMYBCABKANIAogBAUIMCgpfZXhpdF9j" + + "b2RlQg8KDV90aW1lc3RhbXBfbXNCDgoMX2R1cmF0aW9uX21zIlAKCkxzVG9vbENhbGwSHgoEYXJn" + + "cxgBIAEoCzIQLmFnZW50LnYxLkxzQXJncxIiCgZyZXN1bHQYAiABKAsyEi5hZ2VudC52MS5Mc1Jl" + + "c3VsdCK1AQoHTWNwQXJncxIMCgRuYW1lGAEgASgJEikKBGFyZ3MYAiADKAsyGy5hZ2VudC52MS5N" + + "Y3BBcmdzLkFyZ3NFbnRyeRIUCgx0b29sX2NhbGxfaWQYAyABKAkSGwoTcHJvdmlkZXJfaWRlbnRp" + + "ZmllchgEIAEoCRIRCgl0b29sX25hbWUYBSABKAkaKwoJQXJnc0VudHJ5EgsKA2tleRgBIAEoCRIN" + + "CgV2YWx1ZRgCIAEoDDoCOAEi/wEKCU1jcFJlc3VsdBInCgdzdWNjZXNzGAEgASgLMhQuYWdlbnQu" + + "djEuTWNwU3VjY2Vzc0gAEiMKBWVycm9yGAIgASgLMhIuYWdlbnQudjEuTWNwRXJyb3JIABIpCghy" + + "ZWplY3RlZBgDIAEoCzIVLmFnZW50LnYxLk1jcFJlamVjdGVkSAASOgoRcGVybWlzc2lvbl9kZW5p" + + "ZWQYBCABKAsyHS5hZ2VudC52MS5NY3BQZXJtaXNzaW9uRGVuaWVkSAASMwoOdG9vbF9ub3RfZm91" + + "bmQYBSABKAsyGS5hZ2VudC52MS5NY3BUb29sTm90Rm91bmRIAEIICgZyZXN1bHQiOAoPTWNwVG9v" + + "bE5vdEZvdW5kEgwKBG5hbWUYASABKAkSFwoPYXZhaWxhYmxlX3Rvb2xzGAIgAygJImoKDk1jcFRl" + + "eHRDb250ZW50EgwKBHRleHQYASABKAkSNgoPb3V0cHV0X2xvY2F0aW9uGAIgASgLMhguYWdlbnQu" + + "djEuT3V0cHV0TG9jYXRpb25IAIgBAUISChBfb3V0cHV0X2xvY2F0aW9uIjIKD01jcEltYWdlQ29u" + + "dGVudBIMCgRkYXRhGAEgASgMEhEKCW1pbWVfdHlwZRgCIAEoCSJ7ChhNY3BUb29sUmVzdWx0Q29u" + + "dGVudEl0ZW0SKAoEdGV4dBgBIAEoCzIYLmFnZW50LnYxLk1jcFRleHRDb250ZW50SAASKgoFaW1h" + + "Z2UYAiABKAsyGS5hZ2VudC52MS5NY3BJbWFnZUNvbnRlbnRIAEIJCgdjb250ZW50IlMKCk1jcFN1" + + "Y2Nlc3MSMwoHY29udGVudBgBIAMoCzIiLmFnZW50LnYxLk1jcFRvb2xSZXN1bHRDb250ZW50SXRl" + + "bRIQCghpc19lcnJvchgCIAEoCCIZCghNY3BFcnJvchINCgVlcnJvchgBIAEoCSIyCgtNY3BSZWpl" + + "Y3RlZBIOCgZyZWFzb24YASABKAkSEwoLaXNfcmVhZG9ubHkYAiABKAgiOQoTTWNwUGVybWlzc2lv" + + "bkRlbmllZBINCgVlcnJvchgBIAEoCRITCgtpc19yZWFkb25seRgCIAEoCCI6ChhMaXN0TWNwUmVz" + + "b3VyY2VzRXhlY0FyZ3MSEwoGc2VydmVyGAEgASgJSACIAQFCCQoHX3NlcnZlciLGAQoaTGlzdE1j" + + "cFJlc291cmNlc0V4ZWNSZXN1bHQSNAoHc3VjY2VzcxgBIAEoCzIhLmFnZW50LnYxLkxpc3RNY3BS" + + "ZXNvdXJjZXNTdWNjZXNzSAASMAoFZXJyb3IYAiABKAsyHy5hZ2VudC52MS5MaXN0TWNwUmVzb3Vy" + + "Y2VzRXJyb3JIABI2CghyZWplY3RlZBgDIAEoCzIiLmFnZW50LnYxLkxpc3RNY3BSZXNvdXJjZXNS" + + "ZWplY3RlZEgAQggKBnJlc3VsdCK9AgomTGlzdE1jcFJlc291cmNlc0V4ZWNSZXN1bHRfTWNwUmVz" + + "b3VyY2USCwoDdXJpGAEgASgJEhEKBG5hbWUYAiABKAlIAIgBARIYCgtkZXNjcmlwdGlvbhgDIAEo" + + "CUgBiAEBEhYKCW1pbWVfdHlwZRgEIAEoCUgCiAEBEg4KBnNlcnZlchgFIAEoCRJWCgthbm5vdGF0" + + "aW9ucxgGIAMoCzJBLmFnZW50LnYxLkxpc3RNY3BSZXNvdXJjZXNFeGVjUmVzdWx0X01jcFJlc291" + + "cmNlLkFubm90YXRpb25zRW50cnkaMgoQQW5ub3RhdGlvbnNFbnRyeRILCgNrZXkYASABKAkSDQoF" + + "dmFsdWUYAiABKAk6AjgBQgcKBV9uYW1lQg4KDF9kZXNjcmlwdGlvbkIMCgpfbWltZV90eXBlIl4K" + + "F0xpc3RNY3BSZXNvdXJjZXNTdWNjZXNzEkMKCXJlc291cmNlcxgBIAMoCzIwLmFnZW50LnYxLkxp" + + "c3RNY3BSZXNvdXJjZXNFeGVjUmVzdWx0X01jcFJlc291cmNlIiYKFUxpc3RNY3BSZXNvdXJjZXNF" + + "cnJvchINCgVlcnJvchgBIAEoCSIqChhMaXN0TWNwUmVzb3VyY2VzUmVqZWN0ZWQSDgoGcmVhc29u" + + "GAEgASgJImQKF1JlYWRNY3BSZXNvdXJjZUV4ZWNBcmdzEg4KBnNlcnZlchgBIAEoCRILCgN1cmkY" + + "AiABKAkSGgoNZG93bmxvYWRfcGF0aBgDIAEoCUgAiAEBQhAKDl9kb3dubG9hZF9wYXRoIvoBChlS" + + "ZWFkTWNwUmVzb3VyY2VFeGVjUmVzdWx0EjMKB3N1Y2Nlc3MYASABKAsyIC5hZ2VudC52MS5SZWFk" + + "TWNwUmVzb3VyY2VTdWNjZXNzSAASLwoFZXJyb3IYAiABKAsyHi5hZ2VudC52MS5SZWFkTWNwUmVz" + + "b3VyY2VFcnJvckgAEjUKCHJlamVjdGVkGAMgASgLMiEuYWdlbnQudjEuUmVhZE1jcFJlc291cmNl" + + "UmVqZWN0ZWRIABI2Cglub3RfZm91bmQYBCABKAsyIS5hZ2VudC52MS5SZWFkTWNwUmVzb3VyY2VO" + + "b3RGb3VuZEgAQggKBnJlc3VsdCLmAgoWUmVhZE1jcFJlc291cmNlU3VjY2VzcxILCgN1cmkYASAB" + + "KAkSEQoEbmFtZRgCIAEoCUgBiAEBEhgKC2Rlc2NyaXB0aW9uGAMgASgJSAKIAQESFgoJbWltZV90" + + "eXBlGAQgASgJSAOIAQESRgoLYW5ub3RhdGlvbnMYByADKAsyMS5hZ2VudC52MS5SZWFkTWNwUmVz" + + "b3VyY2VTdWNjZXNzLkFubm90YXRpb25zRW50cnkSGgoNZG93bmxvYWRfcGF0aBgIIAEoCUgEiAEB" + + "Eg4KBHRleHQYBSABKAlIABIOCgRibG9iGAYgASgMSAAaMgoQQW5ub3RhdGlvbnNFbnRyeRILCgNr" + + "ZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBQgkKB2NvbnRlbnRCBwoFX25hbWVCDgoMX2Rlc2Ny" + + "aXB0aW9uQgwKCl9taW1lX3R5cGVCEAoOX2Rvd25sb2FkX3BhdGgiMgoUUmVhZE1jcFJlc291cmNl" + + "RXJyb3ISCwoDdXJpGAEgASgJEg0KBWVycm9yGAIgASgJIjYKF1JlYWRNY3BSZXNvdXJjZVJlamVj" + + "dGVkEgsKA3VyaRgBIAEoCRIOCgZyZWFzb24YAiABKAkiJgoXUmVhZE1jcFJlc291cmNlTm90Rm91" + + "bmQSCwoDdXJpGAEgASgJInwKEU1jcFRvb2xEZWZpbml0aW9uEgwKBG5hbWUYASABKAkSGwoTcHJv" + + "dmlkZXJfaWRlbnRpZmllchgEIAEoCRIRCgl0b29sX25hbWUYBSABKAkSEwoLZGVzY3JpcHRpb24Y" + + "AiABKAkSFAoMaW5wdXRfc2NoZW1hGAMgASgMIjoKCE1jcFRvb2xzEi4KCW1jcF90b29scxgBIAMo" + + "CzIbLmFnZW50LnYxLk1jcFRvb2xEZWZpbml0aW9uIjwKD01jcEluc3RydWN0aW9ucxITCgtzZXJ2" + + "ZXJfbmFtZRgBIAEoCRIUCgxpbnN0cnVjdGlvbnMYAiABKAki1wEKDU1jcERlc2NyaXB0b3ISEwoL" + + "c2VydmVyX25hbWUYASABKAkSGQoRc2VydmVyX2lkZW50aWZpZXIYAiABKAkSGAoLZm9sZGVyX3Bh" + + "dGgYAyABKAlIAIgBARIkChdzZXJ2ZXJfdXNlX2luc3RydWN0aW9ucxgEIAEoCUgBiAEBEioKBXRv" + + "b2xzGAUgAygLMhsuYWdlbnQudjEuTWNwVG9vbERlc2NyaXB0b3JCDgoMX2ZvbGRlcl9wYXRoQhoK" + + "GF9zZXJ2ZXJfdXNlX2luc3RydWN0aW9ucyJYChFNY3BUb29sRGVzY3JpcHRvchIRCgl0b29sX25h" + + "bWUYASABKAkSHAoPZGVmaW5pdGlvbl9wYXRoGAIgASgJSACIAQFCEgoQX2RlZmluaXRpb25fcGF0" + + "aCJ4ChRNY3BGaWxlU3lzdGVtT3B0aW9ucxIPCgdlbmFibGVkGAEgASgIEh0KFXdvcmtzcGFjZV9w" + + "cm9qZWN0X2RpchgCIAEoCRIwCg9tY3BfZGVzY3JpcHRvcnMYAyADKAsyFy5hZ2VudC52MS5NY3BE" + + "ZXNjcmlwdG9yIi4KCFJlYWRBcmdzEgwKBHBhdGgYASABKAkSFAoMdG9vbF9jYWxsX2lkGAIgASgJ" + + "IrgCCgpSZWFkUmVzdWx0EigKB3N1Y2Nlc3MYASABKAsyFS5hZ2VudC52MS5SZWFkU3VjY2Vzc0gA" + + "EiQKBWVycm9yGAIgASgLMhMuYWdlbnQudjEuUmVhZEVycm9ySAASKgoIcmVqZWN0ZWQYAyABKAsy" + + "Fi5hZ2VudC52MS5SZWFkUmVqZWN0ZWRIABI0Cg5maWxlX25vdF9mb3VuZBgEIAEoCzIaLmFnZW50" + + "LnYxLlJlYWRGaWxlTm90Rm91bmRIABI7ChFwZXJtaXNzaW9uX2RlbmllZBgFIAEoCzIeLmFnZW50" + + "LnYxLlJlYWRQZXJtaXNzaW9uRGVuaWVkSAASMQoMaW52YWxpZF9maWxlGAYgASgLMhkuYWdlbnQu" + + "djEuUmVhZEludmFsaWRGaWxlSABCCAoGcmVzdWx0IrMBCgtSZWFkU3VjY2VzcxIMCgRwYXRoGAEg" + + "ASgJEhMKC3RvdGFsX2xpbmVzGAMgASgFEhEKCWZpbGVfc2l6ZRgEIAEoAxIRCgl0cnVuY2F0ZWQY" + + "BiABKAgSGwoOb3V0cHV0X2Jsb2JfaWQYByABKAxIAYgBARIRCgdjb250ZW50GAIgASgJSAASDgoE" + + "ZGF0YRgFIAEoDEgAQggKBm91dHB1dEIRCg9fb3V0cHV0X2Jsb2JfaWQiKAoJUmVhZEVycm9yEgwK" + + "BHBhdGgYASABKAkSDQoFZXJyb3IYAiABKAkiLAoMUmVhZFJlamVjdGVkEgwKBHBhdGgYASABKAkS" + + "DgoGcmVhc29uGAIgASgJIiAKEFJlYWRGaWxlTm90Rm91bmQSDAoEcGF0aBgBIAEoCSIkChRSZWFk" + + "UGVybWlzc2lvbkRlbmllZBIMCgRwYXRoGAEgASgJIi8KD1JlYWRJbnZhbGlkRmlsZRIMCgRwYXRo" + + "GAEgASgJEg4KBnJlYXNvbhgCIAEoCSJeCgxSZWFkVG9vbENhbGwSJAoEYXJncxgBIAEoCzIWLmFn" + + "ZW50LnYxLlJlYWRUb29sQXJncxIoCgZyZXN1bHQYAiABKAsyGC5hZ2VudC52MS5SZWFkVG9vbFJl" + + "c3VsdCJaCgxSZWFkVG9vbEFyZ3MSDAoEcGF0aBgBIAEoCRITCgZvZmZzZXQYAiABKAVIAIgBARIS" + + "CgVsaW1pdBgDIAEoBUgBiAEBQgkKB19vZmZzZXRCCAoGX2xpbWl0InIKDlJlYWRUb29sUmVzdWx0" + + "EiwKB3N1Y2Nlc3MYASABKAsyGS5hZ2VudC52MS5SZWFkVG9vbFN1Y2Nlc3NIABIoCgVlcnJvchgC" + + "IAEoCzIXLmFnZW50LnYxLlJlYWRUb29sRXJyb3JIAEIICgZyZXN1bHQiMQoJUmVhZFJhbmdlEhIK" + + "CnN0YXJ0X2xpbmUYASABKA0SEAoIZW5kX2xpbmUYAiABKA0ijgIKD1JlYWRUb29sU3VjY2VzcxIQ" + + "Cghpc19lbXB0eRgCIAEoCBIWCg5leGNlZWRlZF9saW1pdBgDIAEoCBITCgt0b3RhbF9saW5lcxgE" + + "IAEoDRIRCglmaWxlX3NpemUYBSABKA0SDAoEcGF0aBgHIAEoCRIsCgpyZWFkX3JhbmdlGAggASgL" + + "MhMuYWdlbnQudjEuUmVhZFJhbmdlSAGIAQESEQoHY29udGVudBgBIAEoCUgAEg4KBGRhdGEYBiAB" + + "KAxIABIWCgxkYXRhX2Jsb2JfaWQYCSABKAxIABIZCg9jb250ZW50X2Jsb2JfaWQYCiABKAxIAEII" + + "CgZvdXRwdXRCDQoLX3JlYWRfcmFuZ2UiJgoNUmVhZFRvb2xFcnJvchIVCg1lcnJvcl9tZXNzYWdl" + + "GAEgASgJImoKEFJlY29yZFNjcmVlbkFyZ3MSDAoEbW9kZRgBIAEoBRIUCgx0b29sX2NhbGxfaWQY" + + "AiABKAkSHQoQc2F2ZV9hc19maWxlbmFtZRgDIAEoCUgAiAEBQhMKEV9zYXZlX2FzX2ZpbGVuYW1l" + + "IokCChJSZWNvcmRTY3JlZW5SZXN1bHQSOwoNc3RhcnRfc3VjY2VzcxgBIAEoCzIiLmFnZW50LnYx" + + "LlJlY29yZFNjcmVlblN0YXJ0U3VjY2Vzc0gAEjkKDHNhdmVfc3VjY2VzcxgCIAEoCzIhLmFnZW50" + + "LnYxLlJlY29yZFNjcmVlblNhdmVTdWNjZXNzSAASPwoPZGlzY2FyZF9zdWNjZXNzGAMgASgLMiQu" + + "YWdlbnQudjEuUmVjb3JkU2NyZWVuRGlzY2FyZFN1Y2Nlc3NIABIwCgdmYWlsdXJlGAQgASgLMh0u" + + "YWdlbnQudjEuUmVjb3JkU2NyZWVuRmFpbHVyZUgAQggKBnJlc3VsdCJnChhSZWNvcmRTY3JlZW5T" + + "dGFydFN1Y2Nlc3MSJQodd2FzX3ByaW9yX3JlY29yZGluZ19jYW5jZWxsZWQYASABKAgSJAocd2Fz" + + "X3NhdmVfYXNfZmlsZW5hbWVfaWdub3JlZBgCIAEoCCKgAQoXUmVjb3JkU2NyZWVuU2F2ZVN1Y2Nl" + + "c3MSDAoEcGF0aBgBIAEoCRIdChVyZWNvcmRpbmdfZHVyYXRpb25fbXMYAiABKAMSMAojcmVxdWVz" + + "dGVkX2ZpbGVfcGF0aF9yZWplY3RlZF9yZWFzb24YAyABKAVIAIgBAUImCiRfcmVxdWVzdGVkX2Zp" + + "bGVfcGF0aF9yZWplY3RlZF9yZWFzb24iHAoaUmVjb3JkU2NyZWVuRGlzY2FyZFN1Y2Nlc3MiJAoT" + + "UmVjb3JkU2NyZWVuRmFpbHVyZRINCgVlcnJvchgBIAEoCSI2ChNDdXJzb3JQYWNrYWdlUHJvbXB0" + + "EgwKBG5hbWUYASABKAkSEQoJZmlsZV9wYXRoGAIgASgJIuIBCg1DdXJzb3JQYWNrYWdlEgwKBG5h" + + "bWUYASABKAkSEwoLZGVzY3JpcHRpb24YAiABKAkSEwoLZm9sZGVyX3BhdGgYAyABKAkSDwoHZW5h" + + "YmxlZBgEIAEoCBIYCgtwYXJzZV9lcnJvchgFIAEoCUgAiAEBEi4KB3Byb21wdHMYBiADKAsyHS5h" + + "Z2VudC52MS5DdXJzb3JQYWNrYWdlUHJvbXB0EhgKEHJlYWRtZV9maWxlX3BhdGgYByABKAkSFAoM" + + "cGFja2FnZV90eXBlGAggASgFQg4KDF9wYXJzZV9lcnJvciKrAgoWUmVwb3NpdG9yeUluZGV4aW5n" + + "SW5mbxIfChdyZWxhdGl2ZV93b3Jrc3BhY2VfcGF0aBgBIAEoCRITCgtyZW1vdGVfdXJscxgCIAMo" + + "CRIUCgxyZW1vdGVfbmFtZXMYAyADKAkSEQoJcmVwb19uYW1lGAQgASgJEhIKCnJlcG9fb3duZXIY" + + "BSABKAkSEgoKaXNfdHJhY2tlZBgGIAEoCBIQCghpc19sb2NhbBgHIAEoCBImChlvcnRob2dvbmFs" + + "X3RyYW5zZm9ybV9zZWVkGAggASgBSACIAQESFQoNd29ya3NwYWNlX3VyaRgJIAEoCRIbChNwYXRo" + + "X2VuY3J5cHRpb25fa2V5GAogASgJQhwKGl9vcnRob2dvbmFsX3RyYW5zZm9ybV9zZWVkInQKElJl" + + "cXVlc3RDb250ZXh0QXJncxIdChBub3Rlc19zZXNzaW9uX2lkGAIgASgJSACIAQESGQoMd29ya3Nw" + + "YWNlX2lkGAMgASgJSAGIAQFCEwoRX25vdGVzX3Nlc3Npb25faWRCDwoNX3dvcmtzcGFjZV9pZCK6" + + "AQoUUmVxdWVzdENvbnRleHRSZXN1bHQSMgoHc3VjY2VzcxgBIAEoCzIfLmFnZW50LnYxLlJlcXVl" + + "c3RDb250ZXh0U3VjY2Vzc0gAEi4KBWVycm9yGAIgASgLMh0uYWdlbnQudjEuUmVxdWVzdENvbnRl" + + "eHRFcnJvckgAEjQKCHJlamVjdGVkGAMgASgLMiAuYWdlbnQudjEuUmVxdWVzdENvbnRleHRSZWpl" + + "Y3RlZEgAQggKBnJlc3VsdCJKChVSZXF1ZXN0Q29udGV4dFN1Y2Nlc3MSMQoPcmVxdWVzdF9jb250" + + "ZXh0GAEgASgLMhguYWdlbnQudjEuUmVxdWVzdENvbnRleHQiJAoTUmVxdWVzdENvbnRleHRFcnJv" + + "chINCgVlcnJvchgBIAEoCSIoChZSZXF1ZXN0Q29udGV4dFJlamVjdGVkEg4KBnJlYXNvbhgBIAEo" + + "CSLCAQoKSW1hZ2VQcm90bxIMCgRkYXRhGAEgASgMEgwKBHV1aWQYAiABKAkSDAoEcGF0aBgDIAEo" + + "CRIxCglkaW1lbnNpb24YBCABKAsyHi5hZ2VudC52MS5JbWFnZVByb3RvX0RpbWVuc2lvbhImChl0" + + "YXNrX3NwZWNpZmljX2Rlc2NyaXB0aW9uGAYgASgJSACIAQESEQoJbWltZV90eXBlGAcgASgJQhwK" + + "Gl90YXNrX3NwZWNpZmljX2Rlc2NyaXB0aW9uIjUKFEltYWdlUHJvdG9fRGltZW5zaW9uEg0KBXdp" + + "ZHRoGAEgASgFEg4KBmhlaWdodBgCIAEoBSJoCgtHaXRSZXBvSW5mbxIMCgRwYXRoGAEgASgJEg4K" + + "BnN0YXR1cxgCIAEoCRITCgticmFuY2hfbmFtZRgDIAEoCRIXCgpyZW1vdGVfdXJsGAQgASgJSACI" + + "AQFCDQoLX3JlbW90ZV91cmwimwIKEVJlcXVlc3RDb250ZXh0RW52EhIKCm9zX3ZlcnNpb24YASAB" + + "KAkSFwoPd29ya3NwYWNlX3BhdGhzGAIgAygJEg0KBXNoZWxsGAMgASgJEhcKD3NhbmRib3hfZW5h" + + "YmxlZBgFIAEoCBIYChB0ZXJtaW5hbHNfZm9sZGVyGAcgASgJEiEKGWFnZW50X3NoYXJlZF9ub3Rl" + + "c19mb2xkZXIYCCABKAkSJwofYWdlbnRfY29udmVyc2F0aW9uX25vdGVzX2ZvbGRlchgJIAEoCRIR" + + "Cgl0aW1lX3pvbmUYCiABKAkSFgoOcHJvamVjdF9mb2xkZXIYCyABKAkSIAoYYWdlbnRfdHJhbnNj" + + "cmlwdHNfZm9sZGVyGAwgASgJIjwKD0RlYnVnTW9kZUNvbmZpZxIQCghsb2dfcGF0aBgBIAEoCRIX" + + "Cg9zZXJ2ZXJfZW5kcG9pbnQYAiABKAkitAEKD1NraWxsRGVzY3JpcHRvchIMCgRuYW1lGAEgASgJ" + + "EhMKC2Rlc2NyaXB0aW9uGAIgASgJEhMKC2ZvbGRlcl9wYXRoGAMgASgJEg8KB2VuYWJsZWQYBCAB" + + "KAgSGAoLcGFyc2VfZXJyb3IYBSABKAlIAIgBARIYChByZWFkbWVfZmlsZV9wYXRoGAYgASgJEhQK" + + "DHBhY2thZ2VfdHlwZRgHIAEoBUIOCgxfcGFyc2VfZXJyb3IiRAoMU2tpbGxPcHRpb25zEjQKEXNr" + + "aWxsX2Rlc2NyaXB0b3JzGAEgAygLMhkuYWdlbnQudjEuU2tpbGxEZXNjcmlwdG9yIvYICg5SZXF1" + + "ZXN0Q29udGV4dBIjCgVydWxlcxgCIAMoCzIULmFnZW50LnYxLkN1cnNvclJ1bGUSKAoDZW52GAQg" + + "ASgLMhsuYWdlbnQudjEuUmVxdWVzdENvbnRleHRFbnYSOQoPcmVwb3NpdG9yeV9pbmZvGAYgAygL" + + "MiAuYWdlbnQudjEuUmVwb3NpdG9yeUluZGV4aW5nSW5mbxIqCgV0b29scxgHIAMoCzIbLmFnZW50" + + "LnYxLk1jcFRvb2xEZWZpbml0aW9uEicKGmNvbnZlcnNhdGlvbl9ub3Rlc19saXN0aW5nGAggASgJ" + + "SACIAQESIQoUc2hhcmVkX25vdGVzX2xpc3RpbmcYCSABKAlIAYgBARIoCglnaXRfcmVwb3MYCyAD" + + "KAsyFS5hZ2VudC52MS5HaXRSZXBvSW5mbxI2Cg9wcm9qZWN0X2xheW91dHMYDSADKAsyHS5hZ2Vu" + + "dC52MS5Mc0RpcmVjdG9yeVRyZWVOb2RlEjMKEG1jcF9pbnN0cnVjdGlvbnMYDiADKAsyGS5hZ2Vu" + + "dC52MS5NY3BJbnN0cnVjdGlvbnMSOQoRZGVidWdfbW9kZV9jb25maWcYDyABKAsyGS5hZ2VudC52" + + "MS5EZWJ1Z01vZGVDb25maWdIAogBARIXCgpjbG91ZF9ydWxlGBAgASgJSAOIAQESHwoSd2ViX3Nl" + + "YXJjaF9lbmFibGVkGBEgASgISASIAQESMgoNc2tpbGxfb3B0aW9ucxgSIAEoCzIWLmFnZW50LnYx" + + "LlNraWxsT3B0aW9uc0gFiAEBEi4KIXJlcG9zaXRvcnlfaW5mb19zaG91bGRfcXVlcnlfcHJvZBgT" + + "IAEoCEgGiAEBEkEKDWZpbGVfY29udGVudHMYFCADKAsyKi5hZ2VudC52MS5SZXF1ZXN0Q29udGV4" + + "dC5GaWxlQ29udGVudHNFbnRyeRIgChN1c2VyX2ludGVudF9zdW1tYXJ5GBUgASgJSAeIAQESMgoQ" + + "Y3VzdG9tX3N1YmFnZW50cxgWIAMoCzIYLmFnZW50LnYxLkN1c3RvbVN1YmFnZW50EkQKF21jcF9m" + + "aWxlX3N5c3RlbV9vcHRpb25zGBcgASgLMh4uYWdlbnQudjEuTWNwRmlsZVN5c3RlbU9wdGlvbnNI" + + "CIgBARozChFGaWxlQ29udGVudHNFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgB" + + "Qh0KG19jb252ZXJzYXRpb25fbm90ZXNfbGlzdGluZ0IXChVfc2hhcmVkX25vdGVzX2xpc3RpbmdC" + + "FAoSX2RlYnVnX21vZGVfY29uZmlnQg0KC19jbG91ZF9ydWxlQhUKE193ZWJfc2VhcmNoX2VuYWJs" + + "ZWRCEAoOX3NraWxsX29wdGlvbnNCJAoiX3JlcG9zaXRvcnlfaW5mb19zaG91bGRfcXVlcnlfcHJv" + + "ZEIWChRfdXNlcl9pbnRlbnRfc3VtbWFyeUIaChhfbWNwX2ZpbGVfc3lzdGVtX29wdGlvbnMisgIK" + + "DVNhbmRib3hQb2xpY3kSDAoEdHlwZRgBIAEoBRIbCg5uZXR3b3JrX2FjY2VzcxgCIAEoCEgAiAEB" + + "EiIKGmFkZGl0aW9uYWxfcmVhZHdyaXRlX3BhdGhzGAMgAygJEiEKGWFkZGl0aW9uYWxfcmVhZG9u" + + "bHlfcGF0aHMYBCADKAkSHQoQZGVidWdfb3V0cHV0X2RpchgFIAEoCUgBiAEBEh0KEGJsb2NrX2dp" + + "dF93cml0ZXMYBiABKAhIAogBARIeChFkaXNhYmxlX3RtcF93cml0ZRgHIAEoCEgDiAEBQhEKD19u" + + "ZXR3b3JrX2FjY2Vzc0ITChFfZGVidWdfb3V0cHV0X2RpckITChFfYmxvY2tfZ2l0X3dyaXRlc0IU" + + "ChJfZGlzYWJsZV90bXBfd3JpdGUi7wEKDVNlbGVjdGVkSW1hZ2USDAoEdXVpZBgCIAEoCRIMCgRw" + + "YXRoGAMgASgJEjQKCWRpbWVuc2lvbhgEIAEoCzIhLmFnZW50LnYxLlNlbGVjdGVkSW1hZ2VfRGlt" + + "ZW5zaW9uEhEKCW1pbWVfdHlwZRgHIAEoCRIRCgdibG9iX2lkGAEgASgMSAASDgoEZGF0YRgIIAEo" + + "DEgAEkMKEWJsb2JfaWRfd2l0aF9kYXRhGAkgASgLMiYuYWdlbnQudjEuU2VsZWN0ZWRJbWFnZV9C" + + "bG9iSWRXaXRoRGF0YUgAQhEKD2RhdGFfb3JfYmxvYl9pZCI9ChxTZWxlY3RlZEltYWdlX0Jsb2JJ" + + "ZFdpdGhEYXRhEg8KB2Jsb2JfaWQYASABKAwSDAoEZGF0YRgCIAEoDCI4ChdTZWxlY3RlZEltYWdl" + + "X0RpbWVuc2lvbhINCgV3aWR0aBgBIAEoBRIOCgZoZWlnaHQYAiABKAUiSQoRRXh0cmFDb250ZXh0" + + "RW50cnkSDgoEZGF0YRgBIAEoCUgAEhEKB2Jsb2JfaWQYAiABKAxIAEIRCg9kYXRhX29yX2Jsb2Jf" + + "aWQiWwoMU2VsZWN0ZWRGaWxlEg8KB2NvbnRlbnQYASABKAkSDAoEcGF0aBgCIAEoCRIaCg1yZWxh" + + "dGl2ZV9wYXRoGAMgASgJSACIAQFCEAoOX3JlbGF0aXZlX3BhdGgihAEKFVNlbGVjdGVkQ29kZVNl" + + "bGVjdGlvbhIPCgdjb250ZW50GAEgASgJEgwKBHBhdGgYAiABKAkSGgoNcmVsYXRpdmVfcGF0aBgD" + + "IAEoCUgAiAEBEh4KBXJhbmdlGAQgASgLMg8uYWdlbnQudjEuUmFuZ2VCEAoOX3JlbGF0aXZlX3Bh" + + "dGgiXQoQU2VsZWN0ZWRUZXJtaW5hbBIPCgdjb250ZW50GAEgASgJEhIKBXRpdGxlGAIgASgJSACI" + + "AQESEQoEcGF0aBgDIAEoCUgBiAEBQggKBl90aXRsZUIHCgVfcGF0aCKGAQoZU2VsZWN0ZWRUZXJt" + + "aW5hbFNlbGVjdGlvbhIPCgdjb250ZW50GAEgASgJEhIKBXRpdGxlGAIgASgJSACIAQESEQoEcGF0" + + "aBgDIAEoCUgBiAEBEh4KBXJhbmdlGAQgASgLMg8uYWdlbnQudjEuUmFuZ2VCCAoGX3RpdGxlQgcK" + + "BV9wYXRoIoMBCg5TZWxlY3RlZEZvbGRlchIMCgRwYXRoGAEgASgJEhoKDXJlbGF0aXZlX3BhdGgY" + + "AiABKAlIAIgBARI1Cg5kaXJlY3RvcnlfdHJlZRgDIAEoCzIdLmFnZW50LnYxLkxzRGlyZWN0b3J5" + + "VHJlZU5vZGVCEAoOX3JlbGF0aXZlX3BhdGginwEKFFNlbGVjdGVkRXh0ZXJuYWxMaW5rEgsKA3Vy" + + "bBgBIAEoCRIMCgR1dWlkGAIgASgJEhgKC3BkZl9jb250ZW50GAMgASgJSACIAQESEwoGaXNfcGRm" + + "GAQgASgISAGIAQESFQoIZmlsZW5hbWUYBSABKAlIAogBAUIOCgxfcGRmX2NvbnRlbnRCCQoHX2lz" + + "X3BkZkILCglfZmlsZW5hbWUiOAoSU2VsZWN0ZWRDdXJzb3JSdWxlEiIKBHJ1bGUYASABKAsyFC5h" + + "Z2VudC52MS5DdXJzb3JSdWxlIiIKD1NlbGVjdGVkR2l0RGlmZhIPCgdjb250ZW50GAEgASgJIjIK" + + "H1NlbGVjdGVkR2l0RGlmZkZyb21CcmFuY2hUb01haW4SDwoHY29udGVudBgBIAEoCSJpChFTZWxl" + + "Y3RlZEdpdENvbW1pdBILCgNzaGEYASABKAkSDwoHbWVzc2FnZRgCIAEoCRIYCgtkZXNjcmlwdGlv" + + "bhgDIAEoCUgAiAEBEgwKBGRpZmYYBCABKAlCDgoMX2Rlc2NyaXB0aW9uIt0BChNTZWxlY3RlZFB1" + + "bGxSZXF1ZXN0Eg4KBm51bWJlchgBIAEoBRILCgN1cmwYAiABKAkSEgoFdGl0bGUYAyABKAlIAIgB" + + "ARITCgtmb2xkZXJfcGF0aBgEIAEoCRIZCgxzdW1tYXJ5X2pzb24YBSABKAlIAYgBARIYCgtkZXNj" + + "cmlwdGlvbhgGIAEoCUgCiAEBEhQKB2Jsb2JfaWQYByABKAxIA4gBAUIICgZfdGl0bGVCDwoNX3N1" + + "bW1hcnlfanNvbkIOCgxfZGVzY3JpcHRpb25CCgoIX2Jsb2JfaWQiswEKGlNlbGVjdGVkR2l0UFJE" + + "aWZmU2VsZWN0aW9uEg4KBnByX3VybBgBIAEoCRIRCglmaWxlX3BhdGgYAiABKAkSEgoKc3RhcnRf" + + "bGluZRgDIAEoBRIQCghlbmRfbGluZRgEIAEoBRIZCgxkaWZmX2NvbnRlbnQYBSABKAlIAIgBARIU" + + "CgdibG9iX2lkGAYgASgMSAGIAQFCDwoNX2RpZmZfY29udGVudEIKCghfYmxvYl9pZCI2ChVTZWxl" + + "Y3RlZEN1cnNvckNvbW1hbmQSDAoEbmFtZRgBIAEoCRIPCgdjb250ZW50GAIgASgJIjUKFVNlbGVj" + + "dGVkRG9jdW1lbnRhdGlvbhIOCgZkb2NfaWQYASABKAkSDAoEbmFtZRgCIAEoCSIyChBTZWxlY3Rl" + + "ZFBhc3RDaGF0EhAKCGFnZW50X2lkGAEgASgJEgwKBG5hbWUYAiABKAkiqwEKCUNhbGxGcmFtZRIa" + + "Cg1mdW5jdGlvbl9uYW1lGAEgASgJSACIAQESEAoDdXJsGAIgASgJSAGIAQESGAoLbGluZV9udW1i" + + "ZXIYAyABKAVIAogBARIaCg1jb2x1bW5fbnVtYmVyGAQgASgFSAOIAQFCEAoOX2Z1bmN0aW9uX25h" + + "bWVCBgoEX3VybEIOCgxfbGluZV9udW1iZXJCEAoOX2NvbHVtbl9udW1iZXIiaAoKU3RhY2tUcmFj" + + "ZRIoCgtjYWxsX2ZyYW1lcxgBIAMoCzITLmFnZW50LnYxLkNhbGxGcmFtZRIcCg9yYXdfc3RhY2tf" + + "dHJhY2UYAiABKAlIAIgBAUISChBfcmF3X3N0YWNrX3RyYWNlIuQBChJTZWxlY3RlZENvbnNvbGVM" + + "b2cSDwoHbWVzc2FnZRgBIAEoCRIRCgl0aW1lc3RhbXAYAiABKAESDQoFbGV2ZWwYAyABKAkSEwoL" + + "Y2xpZW50X25hbWUYBCABKAkSEgoKc2Vzc2lvbl9pZBgFIAEoCRIuCgtzdGFja190cmFjZRgGIAEo" + + "CzIULmFnZW50LnYxLlN0YWNrVHJhY2VIAIgBARIdChBvYmplY3RfZGF0YV9qc29uGAcgASgJSAGI" + + "AQFCDgoMX3N0YWNrX3RyYWNlQhMKEV9vYmplY3RfZGF0YV9qc29uIroBChFTZWxlY3RlZFVJRWxl" + + "bWVudBIPCgdlbGVtZW50GAEgASgJEg0KBXhwYXRoGAIgASgJEhQKDHRleHRfY29udGVudBgDIAEo" + + "CRINCgVleHRyYRgEIAEoCRIWCgljb21wb25lbnQYBSABKAlIAIgBARIhChRjb21wb25lbnRfcHJv" + + "cHNfanNvbhgGIAEoCUgBiAEBQgwKCl9jb21wb25lbnRCFwoVX2NvbXBvbmVudF9wcm9wc19qc29u" + + "IiAKEFNlbGVjdGVkU3ViYWdlbnQSDAoEbmFtZRgBIAEoCSKCCgoPU2VsZWN0ZWRDb250ZXh0EjAK" + + "D3NlbGVjdGVkX2ltYWdlcxgBIAMoCzIXLmFnZW50LnYxLlNlbGVjdGVkSW1hZ2USPAoSaW52b2Nh" + + "dGlvbl9jb250ZXh0GAIgASgLMhsuYWdlbnQudjEuSW52b2NhdGlvbkNvbnRleHRIAIgBARIVCg1l" + + "eHRyYV9jb250ZXh0GAMgAygJEjoKFWV4dHJhX2NvbnRleHRfZW50cmllcxgQIAMoCzIbLmFnZW50" + + "LnYxLkV4dHJhQ29udGV4dEVudHJ5EiUKBWZpbGVzGAQgAygLMhYuYWdlbnQudjEuU2VsZWN0ZWRG" + + "aWxlEjgKD2NvZGVfc2VsZWN0aW9ucxgFIAMoCzIfLmFnZW50LnYxLlNlbGVjdGVkQ29kZVNlbGVj" + + "dGlvbhItCgl0ZXJtaW5hbHMYBiADKAsyGi5hZ2VudC52MS5TZWxlY3RlZFRlcm1pbmFsEkAKE3Rl" + + "cm1pbmFsX3NlbGVjdGlvbnMYByADKAsyIy5hZ2VudC52MS5TZWxlY3RlZFRlcm1pbmFsU2VsZWN0" + + "aW9uEikKB2ZvbGRlcnMYCCADKAsyGC5hZ2VudC52MS5TZWxlY3RlZEZvbGRlchI2Cg5leHRlcm5h" + + "bF9saW5rcxgJIAMoCzIeLmFnZW50LnYxLlNlbGVjdGVkRXh0ZXJuYWxMaW5rEjIKDGN1cnNvcl9y" + + "dWxlcxgKIAMoCzIcLmFnZW50LnYxLlNlbGVjdGVkQ3Vyc29yUnVsZRIwCghnaXRfZGlmZhgSIAEo" + + "CzIZLmFnZW50LnYxLlNlbGVjdGVkR2l0RGlmZkgBiAEBElQKHGdpdF9kaWZmX2Zyb21fYnJhbmNo" + + "X3RvX21haW4YCyABKAsyKS5hZ2VudC52MS5TZWxlY3RlZEdpdERpZmZGcm9tQnJhbmNoVG9NYWlu" + + "SAKIAQESOAoPY3Vyc29yX2NvbW1hbmRzGAwgAygLMh8uYWdlbnQudjEuU2VsZWN0ZWRDdXJzb3JD" + + "b21tYW5kEjcKDmRvY3VtZW50YXRpb25zGA0gAygLMh8uYWdlbnQudjEuU2VsZWN0ZWREb2N1bWVu" + + "dGF0aW9uEjAKC3VpX2VsZW1lbnRzGA4gAygLMhsuYWdlbnQudjEuU2VsZWN0ZWRVSUVsZW1lbnQS" + + "MgoMY29uc29sZV9sb2dzGA8gAygLMhwuYWdlbnQudjEuU2VsZWN0ZWRDb25zb2xlTG9nEjAKC2dp" + + "dF9jb21taXRzGBEgAygLMhsuYWdlbnQudjEuU2VsZWN0ZWRHaXRDb21taXQSLgoKcGFzdF9jaGF0" + + "cxgTIAMoCzIaLmFnZW50LnYxLlNlbGVjdGVkUGFzdENoYXQSRAoWZ2l0X3ByX2RpZmZfc2VsZWN0" + + "aW9ucxgUIAMoCzIkLmFnZW50LnYxLlNlbGVjdGVkR2l0UFJEaWZmU2VsZWN0aW9uEj0KFnNlbGVj" + + "dGVkX3B1bGxfcmVxdWVzdHMYFSADKAsyHS5hZ2VudC52MS5TZWxlY3RlZFB1bGxSZXF1ZXN0EjYK" + + "EnNlbGVjdGVkX3N1YmFnZW50cxgWIAMoCzIaLmFnZW50LnYxLlNlbGVjdGVkU3ViYWdlbnRCFQoT" + + "X2ludm9jYXRpb25fY29udGV4dEILCglfZ2l0X2RpZmZCHwodX2dpdF9kaWZmX2Zyb21fYnJhbmNo" + + "X3RvX21haW4i5QEKEUludm9jYXRpb25Db250ZXh0Ej8KDHNsYWNrX3RocmVhZBgBIAEoCzInLmFn" + + "ZW50LnYxLkludm9jYXRpb25Db250ZXh0X1NsYWNrVGhyZWFkSAASOQoJZ2l0aHViX3ByGAIgASgL" + + "MiQuYWdlbnQudjEuSW52b2NhdGlvbkNvbnRleHRfR2l0aHViUFJIABI5CglpZGVfc3RhdGUYAyAB" + + "KAsyJC5hZ2VudC52MS5JbnZvY2F0aW9uQ29udGV4dF9JZGVTdGF0ZUgAEhEKB2Jsb2JfaWQYCiAB" + + "KAxIAEIGCgRkYXRhIrsBCh1JbnZvY2F0aW9uQ29udGV4dF9TbGFja1RocmVhZBIOCgZ0aHJlYWQY" + + "ASABKAkSGQoMY2hhbm5lbF9uYW1lGAIgASgJSACIAQESHAoPY2hhbm5lbF9wdXJwb3NlGAMgASgJ" + + "SAGIAQESGgoNY2hhbm5lbF90b3BpYxgEIAEoCUgCiAEBQg8KDV9jaGFubmVsX25hbWVCEgoQX2No" + + "YW5uZWxfcHVycG9zZUIQCg5fY2hhbm5lbF90b3BpYyJ8ChpJbnZvY2F0aW9uQ29udGV4dF9HaXRo" + + "dWJQUhINCgV0aXRsZRgBIAEoCRITCgtkZXNjcmlwdGlvbhgCIAEoCRIQCghjb21tZW50cxgDIAEo" + + "CRIYCgtjaV9mYWlsdXJlcxgEIAEoCUgAiAEBQg4KDF9jaV9mYWlsdXJlcyL+AQoaSW52b2NhdGlv" + + "bkNvbnRleHRfSWRlU3RhdGUSQAoNdmlzaWJsZV9maWxlcxgBIAMoCzIpLmFnZW50LnYxLkludm9j" + + "YXRpb25Db250ZXh0X0lkZVN0YXRlX0ZpbGUSSAoVcmVjZW50bHlfdmlld2VkX2ZpbGVzGAIgAygL" + + "MikuYWdlbnQudjEuSW52b2NhdGlvbkNvbnRleHRfSWRlU3RhdGVfRmlsZRJUChRjdXJyZW50bHlf" + + "dmlld2VkX3BycxgDIAMoCzI2LmFnZW50LnYxLkludm9jYXRpb25Db250ZXh0X0lkZVN0YXRlX1Zp" + + "ZXdlZFB1bGxSZXF1ZXN0Io4CCh9JbnZvY2F0aW9uQ29udGV4dF9JZGVTdGF0ZV9GaWxlEgwKBHBh" + + "dGgYASABKAkSGgoNcmVsYXRpdmVfcGF0aBgCIAEoCUgAiAEBElYKD2N1cnNvcl9wb3NpdGlvbhgD" + + "IAEoCzI4LmFnZW50LnYxLkludm9jYXRpb25Db250ZXh0X0lkZVN0YXRlX0ZpbGVfQ3Vyc29yUG9z" + + "aXRpb25IAYgBARITCgt0b3RhbF9saW5lcxgEIAEoBRIbCg5hY3RpdmVfY29tbWFuZBgFIAEoCUgC" + + "iAEBQhAKDl9yZWxhdGl2ZV9wYXRoQhIKEF9jdXJzb3JfcG9zaXRpb25CEQoPX2FjdGl2ZV9jb21t" + + "YW5kIkwKLkludm9jYXRpb25Db250ZXh0X0lkZVN0YXRlX0ZpbGVfQ3Vyc29yUG9zaXRpb24SDAoE" + + "bGluZRgBIAEoBRIMCgR0ZXh0GAIgASgJIukBCixJbnZvY2F0aW9uQ29udGV4dF9JZGVTdGF0ZV9W" + + "aWV3ZWRQdWxsUmVxdWVzdBIOCgZudW1iZXIYASABKAUSCwoDdXJsGAIgASgJEhIKBXRpdGxlGAMg" + + "ASgJSACIAQESGAoLZm9sZGVyX3BhdGgYBCABKAlIAYgBARIZCgxzdW1tYXJ5X2pzb24YBSABKAlI" + + "AogBARIYCgtkZXNjcmlwdGlvbhgGIAEoCUgDiAEBQggKBl90aXRsZUIOCgxfZm9sZGVyX3BhdGhC" + + "DwoNX3N1bW1hcnlfanNvbkIOCgxfZGVzY3JpcHRpb24iSAoWU2V0dXBWbUVudmlyb25tZW50QXJn" + + "cxIXCg9pbnN0YWxsX2NvbW1hbmQYAiABKAkSFQoNc3RhcnRfY29tbWFuZBgDIAEoCSJcChhTZXR1" + + "cFZtRW52aXJvbm1lbnRSZXN1bHQSNgoHc3VjY2VzcxgBIAEoCzIjLmFnZW50LnYxLlNldHVwVm1F" + + "bnZpcm9ubWVudFN1Y2Nlc3NIAEIICgZyZXN1bHQiGwoZU2V0dXBWbUVudmlyb25tZW50U3VjY2Vz" + + "cyKAAQoaU2V0dXBWbUVudmlyb25tZW50VG9vbENhbGwSLgoEYXJncxgBIAEoCzIgLmFnZW50LnYx" + + "LlNldHVwVm1FbnZpcm9ubWVudEFyZ3MSMgoGcmVzdWx0GAIgASgLMiIuYWdlbnQudjEuU2V0dXBW" + + "bUVudmlyb25tZW50UmVzdWx0IsABChlTaGVsbENvbW1hbmRQYXJzaW5nUmVzdWx0EhYKDnBhcnNp" + + "bmdfZmFpbGVkGAEgASgIElIKE2V4ZWN1dGFibGVfY29tbWFuZHMYAiADKAsyNS5hZ2VudC52MS5T" + + "aGVsbENvbW1hbmRQYXJzaW5nUmVzdWx0X0V4ZWN1dGFibGVDb21tYW5kEhUKDWhhc19yZWRpcmVj" + + "dHMYAyABKAgSIAoYaGFzX2NvbW1hbmRfc3Vic3RpdHV0aW9uGAQgASgIIk0KLlNoZWxsQ29tbWFu" + + "ZFBhcnNpbmdSZXN1bHRfRXhlY3V0YWJsZUNvbW1hbmRBcmcSDAoEdHlwZRgBIAEoCRINCgV2YWx1" + + "ZRgCIAEoCSKWAQorU2hlbGxDb21tYW5kUGFyc2luZ1Jlc3VsdF9FeGVjdXRhYmxlQ29tbWFuZBIM" + + "CgRuYW1lGAEgASgJEkYKBGFyZ3MYAiADKAsyOC5hZ2VudC52MS5TaGVsbENvbW1hbmRQYXJzaW5n" + + "UmVzdWx0X0V4ZWN1dGFibGVDb21tYW5kQXJnEhEKCWZ1bGxfdGV4dBgDIAEoCSKIBAoJU2hlbGxB" + + "cmdzEg8KB2NvbW1hbmQYASABKAkSGQoRd29ya2luZ19kaXJlY3RvcnkYAiABKAkSDwoHdGltZW91" + + "dBgDIAEoBRIUCgx0b29sX2NhbGxfaWQYBCABKAkSFwoPc2ltcGxlX2NvbW1hbmRzGAUgAygJEhoK" + + "Emhhc19pbnB1dF9yZWRpcmVjdBgGIAEoCBIbChNoYXNfb3V0cHV0X3JlZGlyZWN0GAcgASgIEjsK" + + "DnBhcnNpbmdfcmVzdWx0GAggASgLMiMuYWdlbnQudjEuU2hlbGxDb21tYW5kUGFyc2luZ1Jlc3Vs" + + "dBI+ChhyZXF1ZXN0ZWRfc2FuZGJveF9wb2xpY3kYCSABKAsyFy5hZ2VudC52MS5TYW5kYm94UG9s" + + "aWN5SACIAQESKAobZmlsZV9vdXRwdXRfdGhyZXNob2xkX2J5dGVzGAogASgESAGIAQESFQoNaXNf" + + "YmFja2dyb3VuZBgLIAEoCBIVCg1za2lwX2FwcHJvdmFsGAwgASgIEhgKEHRpbWVvdXRfYmVoYXZp" + + "b3IYDSABKAUSGQoMaGFyZF90aW1lb3V0GA4gASgFSAKIAQFCGwoZX3JlcXVlc3RlZF9zYW5kYm94" + + "X3BvbGljeUIeChxfZmlsZV9vdXRwdXRfdGhyZXNob2xkX2J5dGVzQg8KDV9oYXJkX3RpbWVvdXQi" + + "+gMKC1NoZWxsUmVzdWx0EjQKDnNhbmRib3hfcG9saWN5GGUgASgLMhcuYWdlbnQudjEuU2FuZGJv" + + "eFBvbGljeUgBiAEBEhoKDWlzX2JhY2tncm91bmQYZiABKAhIAogBARIdChB0ZXJtaW5hbHNfZm9s" + + "ZGVyGGcgASgJSAOIAQESEAoDcGlkGGggASgNSASIAQESKQoHc3VjY2VzcxgBIAEoCzIWLmFnZW50" + + "LnYxLlNoZWxsU3VjY2Vzc0gAEikKB2ZhaWx1cmUYAiABKAsyFi5hZ2VudC52MS5TaGVsbEZhaWx1" + + "cmVIABIpCgd0aW1lb3V0GAMgASgLMhYuYWdlbnQudjEuU2hlbGxUaW1lb3V0SAASKwoIcmVqZWN0" + + "ZWQYBCABKAsyFy5hZ2VudC52MS5TaGVsbFJlamVjdGVkSAASMAoLc3Bhd25fZXJyb3IYBSABKAsy" + + "GS5hZ2VudC52MS5TaGVsbFNwYXduRXJyb3JIABI8ChFwZXJtaXNzaW9uX2RlbmllZBgHIAEoCzIf" + + "LmFnZW50LnYxLlNoZWxsUGVybWlzc2lvbkRlbmllZEgAQggKBnJlc3VsdEIRCg9fc2FuZGJveF9w" + + "b2xpY3lCEAoOX2lzX2JhY2tncm91bmRCEwoRX3Rlcm1pbmFsc19mb2xkZXJCBgoEX3BpZCIhChFT" + + "aGVsbFN0cmVhbVN0ZG91dBIMCgRkYXRhGAEgASgJIiEKEVNoZWxsU3RyZWFtU3RkZXJyEgwKBGRh" + + "dGEYASABKAkitQEKD1NoZWxsU3RyZWFtRXhpdBIMCgRjb2RlGAEgASgNEgsKA2N3ZBgCIAEoCRI2" + + "Cg9vdXRwdXRfbG9jYXRpb24YAyABKAsyGC5hZ2VudC52MS5PdXRwdXRMb2NhdGlvbkgAiAEBEg8K" + + "B2Fib3J0ZWQYBCABKAgSGQoMYWJvcnRfcmVhc29uGAUgASgFSAGIAQFCEgoQX291dHB1dF9sb2Nh" + + "dGlvbkIPCg1fYWJvcnRfcmVhc29uIlsKEFNoZWxsU3RyZWFtU3RhcnQSNAoOc2FuZGJveF9wb2xp" + + "Y3kYASABKAsyFy5hZ2VudC52MS5TYW5kYm94UG9saWN5SACIAQFCEQoPX3NhbmRib3hfcG9saWN5" + + "IpkBChdTaGVsbFN0cmVhbUJhY2tncm91bmRlZBIQCghzaGVsbF9pZBgBIAEoDRIPCgdjb21tYW5k" + + "GAIgASgJEhkKEXdvcmtpbmdfZGlyZWN0b3J5GAMgASgJEhAKA3BpZBgEIAEoDUgAiAEBEhcKCm1z" + + "X3RvX3dhaXQYBSABKAVIAYgBAUIGCgRfcGlkQg0KC19tc190b193YWl0IvICCgtTaGVsbFN0cmVh" + + "bRItCgZzdGRvdXQYASABKAsyGy5hZ2VudC52MS5TaGVsbFN0cmVhbVN0ZG91dEgAEi0KBnN0ZGVy" + + "chgCIAEoCzIbLmFnZW50LnYxLlNoZWxsU3RyZWFtU3RkZXJySAASKQoEZXhpdBgDIAEoCzIZLmFn" + + "ZW50LnYxLlNoZWxsU3RyZWFtRXhpdEgAEisKBXN0YXJ0GAQgASgLMhouYWdlbnQudjEuU2hlbGxT" + + "dHJlYW1TdGFydEgAEisKCHJlamVjdGVkGAUgASgLMhcuYWdlbnQudjEuU2hlbGxSZWplY3RlZEgA" + + "EjwKEXBlcm1pc3Npb25fZGVuaWVkGAYgASgLMh8uYWdlbnQudjEuU2hlbGxQZXJtaXNzaW9uRGVu" + + "aWVkSAASOQoMYmFja2dyb3VuZGVkGAcgASgLMiEuYWdlbnQudjEuU2hlbGxTdHJlYW1CYWNrZ3Jv" + + "dW5kZWRIAEIHCgVldmVudCJLCg5PdXRwdXRMb2NhdGlvbhIRCglmaWxlX3BhdGgYASABKAkSEgoK" + + "c2l6ZV9ieXRlcxgCIAEoAxISCgpsaW5lX2NvdW50GAMgASgDIv8CCgxTaGVsbFN1Y2Nlc3MSDwoH" + + "Y29tbWFuZBgBIAEoCRIZChF3b3JraW5nX2RpcmVjdG9yeRgCIAEoCRIRCglleGl0X2NvZGUYAyAB" + + "KAUSDgoGc2lnbmFsGAQgASgJEg4KBnN0ZG91dBgFIAEoCRIOCgZzdGRlcnIYBiABKAkSFgoOZXhl" + + "Y3V0aW9uX3RpbWUYByABKAUSNgoPb3V0cHV0X2xvY2F0aW9uGAggASgLMhguYWdlbnQudjEuT3V0" + + "cHV0TG9jYXRpb25IAIgBARIVCghzaGVsbF9pZBgJIAEoDUgBiAEBEh8KEmludGVybGVhdmVkX291" + + "dHB1dBgKIAEoCUgCiAEBEhAKA3BpZBgLIAEoDUgDiAEBEhcKCm1zX3RvX3dhaXQYDCABKAVIBIgB" + + "AUISChBfb3V0cHV0X2xvY2F0aW9uQgsKCV9zaGVsbF9pZEIVChNfaW50ZXJsZWF2ZWRfb3V0cHV0" + + "QgYKBF9waWRCDQoLX21zX3RvX3dhaXQi1gIKDFNoZWxsRmFpbHVyZRIPCgdjb21tYW5kGAEgASgJ" + + "EhkKEXdvcmtpbmdfZGlyZWN0b3J5GAIgASgJEhEKCWV4aXRfY29kZRgDIAEoBRIOCgZzaWduYWwY" + + "BCABKAkSDgoGc3Rkb3V0GAUgASgJEg4KBnN0ZGVychgGIAEoCRIWCg5leGVjdXRpb25fdGltZRgH" + + "IAEoBRI2Cg9vdXRwdXRfbG9jYXRpb24YCCABKAsyGC5hZ2VudC52MS5PdXRwdXRMb2NhdGlvbkgA" + + "iAEBEh8KEmludGVybGVhdmVkX291dHB1dBgJIAEoCUgBiAEBEhkKDGFib3J0X3JlYXNvbhgKIAEo" + + "BUgCiAEBEg8KB2Fib3J0ZWQYCyABKAhCEgoQX291dHB1dF9sb2NhdGlvbkIVChNfaW50ZXJsZWF2" + + "ZWRfb3V0cHV0Qg8KDV9hYm9ydF9yZWFzb24iTgoMU2hlbGxUaW1lb3V0Eg8KB2NvbW1hbmQYASAB" + + "KAkSGQoRd29ya2luZ19kaXJlY3RvcnkYAiABKAkSEgoKdGltZW91dF9tcxgDIAEoBSJgCg1TaGVs" + + "bFJlamVjdGVkEg8KB2NvbW1hbmQYASABKAkSGQoRd29ya2luZ19kaXJlY3RvcnkYAiABKAkSDgoG" + + "cmVhc29uGAMgASgJEhMKC2lzX3JlYWRvbmx5GAQgASgIImcKFVNoZWxsUGVybWlzc2lvbkRlbmll" + + "ZBIPCgdjb21tYW5kGAEgASgJEhkKEXdvcmtpbmdfZGlyZWN0b3J5GAIgASgJEg0KBWVycm9yGAMg" + + "ASgJEhMKC2lzX3JlYWRvbmx5GAQgASgIIkwKD1NoZWxsU3Bhd25FcnJvchIPCgdjb21tYW5kGAEg" + + "ASgJEhkKEXdvcmtpbmdfZGlyZWN0b3J5GAIgASgJEg0KBWVycm9yGAMgASgJIkAKElNoZWxsUGFy" + + "dGlhbFJlc3VsdBIUCgxzdGRvdXRfZGVsdGEYASABKAkSFAoMc3RkZXJyX2RlbHRhGAIgASgJIlkK" + + "DVNoZWxsVG9vbENhbGwSIQoEYXJncxgBIAEoCzITLmFnZW50LnYxLlNoZWxsQXJncxIlCgZyZXN1" + + "bHQYAiABKAsyFS5hZ2VudC52MS5TaGVsbFJlc3VsdCIrChhTaGVsbFRvb2xDYWxsU3Rkb3V0RGVs" + + "dGESDwoHY29udGVudBgBIAEoCSIrChhTaGVsbFRvb2xDYWxsU3RkZXJyRGVsdGESDwoHY29udGVu" + + "dBgBIAEoCSKJAQoSU2hlbGxUb29sQ2FsbERlbHRhEjQKBnN0ZG91dBgBIAEoCzIiLmFnZW50LnYx" + + "LlNoZWxsVG9vbENhbGxTdGRvdXREZWx0YUgAEjQKBnN0ZGVychgCIAEoCzIiLmFnZW50LnYxLlNo" + + "ZWxsVG9vbENhbGxTdGRlcnJEZWx0YUgAQgcKBWRlbHRhIu0BCgxTdWJhZ2VudFR5cGUSOAoLdW5z" + + "cGVjaWZpZWQYASABKAsyIS5hZ2VudC52MS5TdWJhZ2VudFR5cGVVbnNwZWNpZmllZEgAEjkKDGNv" + + "bXB1dGVyX3VzZRgCIAEoCzIhLmFnZW50LnYxLlN1YmFnZW50VHlwZUNvbXB1dGVyVXNlSAASLgoG" + + "Y3VzdG9tGAMgASgLMhwuYWdlbnQudjEuU3ViYWdlbnRUeXBlQ3VzdG9tSAASMAoHZXhwbG9yZRgE" + + "IAEoCzIdLmFnZW50LnYxLlN1YmFnZW50VHlwZUV4cGxvcmVIAEIGCgR0eXBlIhkKF1N1YmFnZW50" + + "VHlwZVVuc3BlY2lmaWVkIhkKF1N1YmFnZW50VHlwZUNvbXB1dGVyVXNlIhUKE1N1YmFnZW50VHlw" + + "ZUV4cGxvcmUiIgoSU3ViYWdlbnRUeXBlQ3VzdG9tEgwKBG5hbWUYASABKAkijQEKDkN1c3RvbVN1" + + "YmFnZW50EhEKCWZ1bGxfcGF0aBgBIAEoCRIMCgRuYW1lGAIgASgJEhMKC2Rlc2NyaXB0aW9uGAMg" + + "ASgJEg0KBXRvb2xzGAQgAygJEg0KBW1vZGVsGAUgASgJEg4KBnByb21wdBgGIAEoCRIXCg9wZXJt" + + "aXNzaW9uX21vZGUYByABKAUiaAoOU3dpdGNoTW9kZUFyZ3MSFgoOdGFyZ2V0X21vZGVfaWQYASAB" + + "KAkSGAoLZXhwbGFuYXRpb24YAiABKAlIAIgBARIUCgx0b29sX2NhbGxfaWQYAyABKAlCDgoMX2V4" + + "cGxhbmF0aW9uIqoBChBTd2l0Y2hNb2RlUmVzdWx0Ei4KB3N1Y2Nlc3MYASABKAsyGy5hZ2VudC52" + + "MS5Td2l0Y2hNb2RlU3VjY2Vzc0gAEioKBWVycm9yGAIgASgLMhkuYWdlbnQudjEuU3dpdGNoTW9k" + + "ZUVycm9ySAASMAoIcmVqZWN0ZWQYAyABKAsyHC5hZ2VudC52MS5Td2l0Y2hNb2RlUmVqZWN0ZWRI" + + "AEIICgZyZXN1bHQiPQoRU3dpdGNoTW9kZVN1Y2Nlc3MSFAoMZnJvbV9tb2RlX2lkGAEgASgJEhIK" + + "CnRvX21vZGVfaWQYAiABKAkiIAoPU3dpdGNoTW9kZUVycm9yEg0KBWVycm9yGAEgASgJIiQKElN3" + + "aXRjaE1vZGVSZWplY3RlZBIOCgZyZWFzb24YASABKAkiaAoSU3dpdGNoTW9kZVRvb2xDYWxsEiYK" + + "BGFyZ3MYASABKAsyGC5hZ2VudC52MS5Td2l0Y2hNb2RlQXJncxIqCgZyZXN1bHQYAiABKAsyGi5h" + + "Z2VudC52MS5Td2l0Y2hNb2RlUmVzdWx0IkAKFlN3aXRjaE1vZGVSZXF1ZXN0UXVlcnkSJgoEYXJn" + + "cxgBIAEoCzIYLmFnZW50LnYxLlN3aXRjaE1vZGVBcmdzIqkBChlTd2l0Y2hNb2RlUmVxdWVzdFJl" + + "c3BvbnNlEkAKCGFwcHJvdmVkGAEgASgLMiwuYWdlbnQudjEuU3dpdGNoTW9kZVJlcXVlc3RSZXNw" + + "b25zZV9BcHByb3ZlZEgAEkAKCHJlamVjdGVkGAIgASgLMiwuYWdlbnQudjEuU3dpdGNoTW9kZVJl" + + "cXVlc3RSZXNwb25zZV9SZWplY3RlZEgAQggKBnJlc3VsdCIkCiJTd2l0Y2hNb2RlUmVxdWVzdFJl" + + "c3BvbnNlX0FwcHJvdmVkIjQKIlN3aXRjaE1vZGVSZXF1ZXN0UmVzcG9uc2VfUmVqZWN0ZWQSDgoG" + + "cmVhc29uGAEgASgJInUKCFRvZG9JdGVtEgoKAmlkGAEgASgJEg8KB2NvbnRlbnQYAiABKAkSDgoG" + + "c3RhdHVzGAMgASgFEhIKCmNyZWF0ZWRfYXQYBCABKAMSEgoKdXBkYXRlZF9hdBgFIAEoAxIUCgxk" + + "ZXBlbmRlbmNpZXMYBiADKAkiawoTVXBkYXRlVG9kb3NUb29sQ2FsbBInCgRhcmdzGAEgASgLMhku" + + "YWdlbnQudjEuVXBkYXRlVG9kb3NBcmdzEisKBnJlc3VsdBgCIAEoCzIbLmFnZW50LnYxLlVwZGF0" + + "ZVRvZG9zUmVzdWx0IkMKD1VwZGF0ZVRvZG9zQXJncxIhCgV0b2RvcxgBIAMoCzISLmFnZW50LnYx" + + "LlRvZG9JdGVtEg0KBW1lcmdlGAIgASgIInsKEVVwZGF0ZVRvZG9zUmVzdWx0Ei8KB3N1Y2Nlc3MY" + + "ASABKAsyHC5hZ2VudC52MS5VcGRhdGVUb2Rvc1N1Y2Nlc3NIABIrCgVlcnJvchgCIAEoCzIaLmFn" + + "ZW50LnYxLlVwZGF0ZVRvZG9zRXJyb3JIAEIICgZyZXN1bHQiXwoSVXBkYXRlVG9kb3NTdWNjZXNz" + + "EiEKBXRvZG9zGAEgAygLMhIuYWdlbnQudjEuVG9kb0l0ZW0SEwoLdG90YWxfY291bnQYAiABKAUS" + + "EQoJd2FzX21lcmdlGAMgASgIIiEKEFVwZGF0ZVRvZG9zRXJyb3ISDQoFZXJyb3IYASABKAkiZQoR" + + "UmVhZFRvZG9zVG9vbENhbGwSJQoEYXJncxgBIAEoCzIXLmFnZW50LnYxLlJlYWRUb2Rvc0FyZ3MS" + + "KQoGcmVzdWx0GAIgASgLMhkuYWdlbnQudjEuUmVhZFRvZG9zUmVzdWx0IjkKDVJlYWRUb2Rvc0Fy" + + "Z3MSFQoNc3RhdHVzX2ZpbHRlchgBIAMoBRIRCglpZF9maWx0ZXIYAiADKAkidQoPUmVhZFRvZG9z" + + "UmVzdWx0Ei0KB3N1Y2Nlc3MYASABKAsyGi5hZ2VudC52MS5SZWFkVG9kb3NTdWNjZXNzSAASKQoF" + + "ZXJyb3IYAiABKAsyGC5hZ2VudC52MS5SZWFkVG9kb3NFcnJvckgAQggKBnJlc3VsdCJKChBSZWFk" + + "VG9kb3NTdWNjZXNzEiEKBXRvZG9zGAEgAygLMhIuYWdlbnQudjEuVG9kb0l0ZW0SEwoLdG90YWxf" + + "Y291bnQYAiABKAUiHwoOUmVhZFRvZG9zRXJyb3ISDQoFZXJyb3IYASABKAkiSwoFUmFuZ2USIQoF" + + "c3RhcnQYASABKAsyEi5hZ2VudC52MS5Qb3NpdGlvbhIfCgNlbmQYAiABKAsyEi5hZ2VudC52MS5Q" + + "b3NpdGlvbiIoCghQb3NpdGlvbhIMCgRsaW5lGAEgASgNEg4KBmNvbHVtbhgCIAEoDSIYCgVFcnJv" + + "chIPCgdtZXNzYWdlGAEgASgJIjoKDVdlYlNlYXJjaEFyZ3MSEwoLc2VhcmNoX3Rlcm0YASABKAkS" + + "FAoMdG9vbF9jYWxsX2lkGAIgASgJIqYBCg9XZWJTZWFyY2hSZXN1bHQSLQoHc3VjY2VzcxgBIAEo" + + "CzIaLmFnZW50LnYxLldlYlNlYXJjaFN1Y2Nlc3NIABIpCgVlcnJvchgCIAEoCzIYLmFnZW50LnYx" + + "LldlYlNlYXJjaEVycm9ySAASLwoIcmVqZWN0ZWQYAyABKAsyGy5hZ2VudC52MS5XZWJTZWFyY2hS" + + "ZWplY3RlZEgAQggKBnJlc3VsdCJEChBXZWJTZWFyY2hTdWNjZXNzEjAKCnJlZmVyZW5jZXMYASAD" + + "KAsyHC5hZ2VudC52MS5XZWJTZWFyY2hSZWZlcmVuY2UiHwoOV2ViU2VhcmNoRXJyb3ISDQoFZXJy" + + "b3IYASABKAkiIwoRV2ViU2VhcmNoUmVqZWN0ZWQSDgoGcmVhc29uGAEgASgJIj8KEldlYlNlYXJj" + + "aFJlZmVyZW5jZRINCgV0aXRsZRgBIAEoCRILCgN1cmwYAiABKAkSDQoFY2h1bmsYAyABKAkiZQoR" + + "V2ViU2VhcmNoVG9vbENhbGwSJQoEYXJncxgBIAEoCzIXLmFnZW50LnYxLldlYlNlYXJjaEFyZ3MS" + + "KQoGcmVzdWx0GAIgASgLMhkuYWdlbnQudjEuV2ViU2VhcmNoUmVzdWx0Ij4KFVdlYlNlYXJjaFJl" + + "cXVlc3RRdWVyeRIlCgRhcmdzGAEgASgLMhcuYWdlbnQudjEuV2ViU2VhcmNoQXJncyKmAQoYV2Vi" + + "U2VhcmNoUmVxdWVzdFJlc3BvbnNlEj8KCGFwcHJvdmVkGAEgASgLMisuYWdlbnQudjEuV2ViU2Vh" + + "cmNoUmVxdWVzdFJlc3BvbnNlX0FwcHJvdmVkSAASPwoIcmVqZWN0ZWQYAiABKAsyKy5hZ2VudC52" + + "MS5XZWJTZWFyY2hSZXF1ZXN0UmVzcG9uc2VfUmVqZWN0ZWRIAEIICgZyZXN1bHQiIwohV2ViU2Vh" + + "cmNoUmVxdWVzdFJlc3BvbnNlX0FwcHJvdmVkIjMKIVdlYlNlYXJjaFJlcXVlc3RSZXNwb25zZV9S" + + "ZWplY3RlZBIOCgZyZWFzb24YASABKAkifwoJV3JpdGVBcmdzEgwKBHBhdGgYASABKAkSEQoJZmls" + + "ZV90ZXh0GAIgASgJEhQKDHRvb2xfY2FsbF9pZBgDIAEoCRInCh9yZXR1cm5fZmlsZV9jb250ZW50" + + "X2FmdGVyX3dyaXRlGAQgASgIEhIKCmZpbGVfYnl0ZXMYBSABKAwigAIKC1dyaXRlUmVzdWx0EikK" + + "B3N1Y2Nlc3MYASABKAsyFi5hZ2VudC52MS5Xcml0ZVN1Y2Nlc3NIABI8ChFwZXJtaXNzaW9uX2Rl" + + "bmllZBgDIAEoCzIfLmFnZW50LnYxLldyaXRlUGVybWlzc2lvbkRlbmllZEgAEioKCG5vX3NwYWNl" + + "GAQgASgLMhYuYWdlbnQudjEuV3JpdGVOb1NwYWNlSAASJQoFZXJyb3IYBSABKAsyFC5hZ2VudC52" + + "MS5Xcml0ZUVycm9ySAASKwoIcmVqZWN0ZWQYBiABKAsyFy5hZ2VudC52MS5Xcml0ZVJlamVjdGVk" + + "SABCCAoGcmVzdWx0IooBCgxXcml0ZVN1Y2Nlc3MSDAoEcGF0aBgBIAEoCRIVCg1saW5lc19jcmVh" + + "dGVkGAIgASgFEhEKCWZpbGVfc2l6ZRgDIAEoBRIlChhmaWxlX2NvbnRlbnRfYWZ0ZXJfd3JpdGUY" + + "BCABKAlIAIgBAUIbChlfZmlsZV9jb250ZW50X2FmdGVyX3dyaXRlIm8KFVdyaXRlUGVybWlzc2lv" + + "bkRlbmllZBIMCgRwYXRoGAEgASgJEhEKCWRpcmVjdG9yeRgCIAEoCRIRCglvcGVyYXRpb24YAyAB" + + "KAkSDQoFZXJyb3IYBCABKAkSEwoLaXNfcmVhZG9ubHkYBSABKAgiHAoMV3JpdGVOb1NwYWNlEgwK" + + "BHBhdGgYASABKAkiKQoKV3JpdGVFcnJvchIMCgRwYXRoGAEgASgJEg0KBWVycm9yGAIgASgJIi0K" + + "DVdyaXRlUmVqZWN0ZWQSDAoEcGF0aBgBIAEoCRIOCgZyZWFzb24YAiABKAkigwEKF0Jvb3RzdHJh" + + "cFN0YXRzaWdSZXF1ZXN0Eh4KEWlnbm9yZV9kZXZfc3RhdHVzGAEgASgISACIAQESHQoQb3BlcmF0" + + "aW5nX3N5c3RlbRgCIAEoBUgBiAEBQhQKEl9pZ25vcmVfZGV2X3N0YXR1c0ITChFfb3BlcmF0aW5n" + + "X3N5c3RlbSIOCgxQaW5nUmVzcG9uc2UitwEKC0V4ZWNSZXF1ZXN0Eg8KB2NvbW1hbmQYASABKAkS" + + "EAoDY3dkGAIgASgJSACIAQESDAoEYXJncxgDIAMoCRI7CgtlbnZpcm9ubWVudBgEIAMoCzImLmFn" + + "ZW50LnYxLkV4ZWNSZXF1ZXN0LkVudmlyb25tZW50RW50cnkaMgoQRW52aXJvbm1lbnRFbnRyeRIL" + + "CgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBQgYKBF9jd2QioAEKDEV4ZWNSZXNwb25zZRIt" + + "CgxzdGRvdXRfZXZlbnQYASABKAsyFS5hZ2VudC52MS5TdGRvdXRFdmVudEgAEi0KDHN0ZGVycl9l" + + "dmVudBgCIAEoCzIVLmFnZW50LnYxLlN0ZGVyckV2ZW50SAASKQoKZXhpdF9ldmVudBgDIAEoCzIT" + + "LmFnZW50LnYxLkV4aXRFdmVudEgAQgcKBWV2ZW50IhsKC1N0ZG91dEV2ZW50EgwKBGRhdGEYASAB" + + "KAkiGwoLU3RkZXJyRXZlbnQSDAoEZGF0YRgBIAEoCSIeCglFeGl0RXZlbnQSEQoJZXhpdF9jb2Rl" + + "GAEgASgFIiMKE1JlYWRUZXh0RmlsZVJlcXVlc3QSDAoEcGF0aBgBIAEoCSInChRSZWFkVGV4dEZp" + + "bGVSZXNwb25zZRIPCgdjb250ZW50GAEgASgJIjUKFFdyaXRlVGV4dEZpbGVSZXF1ZXN0EgwKBHBh" + + "dGgYASABKAkSDwoHY29udGVudBgCIAEoCSIXChVXcml0ZVRleHRGaWxlUmVzcG9uc2UiJQoVUmVh" + + "ZEJpbmFyeUZpbGVSZXF1ZXN0EgwKBHBhdGgYASABKAkiKQoWUmVhZEJpbmFyeUZpbGVSZXNwb25z" + + "ZRIPCgdjb250ZW50GAEgASgMIjcKFldyaXRlQmluYXJ5RmlsZVJlcXVlc3QSDAoEcGF0aBgBIAEo" + + "CRIPCgdjb250ZW50GAIgASgMIhkKF1dyaXRlQmluYXJ5RmlsZVJlc3BvbnNlIkUKHkdldFdvcmtz" + + "cGFjZUNoYW5nZXNIYXNoUmVxdWVzdBIRCglyb290X3BhdGgYASABKAkSEAoIYmFzZV9yZWYYAiAB" + + "KAkiLwofR2V0V29ya3NwYWNlQ2hhbmdlc0hhc2hSZXNwb25zZRIMCgRoYXNoGAEgASgJIlAKH1Jl" + + "ZnJlc2hHaXRodWJBY2Nlc3NUb2tlblJlcXVlc3QSGwoTZ2l0aHViX2FjY2Vzc190b2tlbhgBIAEo" + + "CRIQCghob3N0bmFtZRgCIAEoCSIiCiBSZWZyZXNoR2l0aHViQWNjZXNzVG9rZW5SZXNwb25zZSJX" + + "Ch1XYXJtUmVtb3RlQWNjZXNzU2VydmVyUmVxdWVzdBIOCgZjb21taXQYASABKAkSDAoEcG9ydBgC" + + "IAEoBRIYChBjb25uZWN0aW9uX3Rva2VuGAMgASgJIiAKHldhcm1SZW1vdGVBY2Nlc3NTZXJ2ZXJS" + + "ZXNwb25zZSIWChRMaXN0QXJ0aWZhY3RzUmVxdWVzdCKKAgoWQXJ0aWZhY3RVcGxvYWRNZXRhZGF0" + + "YRIVCg1hYnNvbHV0ZV9wYXRoGAEgASgJEhIKCnNpemVfYnl0ZXMYAiABKAQSGgoSdXBkYXRlZF9h" + + "dF91bml4X21zGAMgASgDEg4KBnN0YXR1cxgEIAEoBRIWCg5ieXRlc191cGxvYWRlZBgFIAEoBBIS" + + "CgpsYXN0X2Vycm9yGAYgASgJEhcKD3VwbG9hZF9hdHRlbXB0cxgHIAEoDRIfChdsYXN0X3N0YXJ0" + + "ZWRfYXRfdW5peF9tcxgIIAEoAxIgChhsYXN0X2ZpbmlzaGVkX2F0X3VuaXhfbXMYCSABKAMSEQoJ" + + "dXBsb2FkX2lkGAogASgJIkwKFUxpc3RBcnRpZmFjdHNSZXNwb25zZRIzCglhcnRpZmFjdHMYASAD" + + "KAsyIC5hZ2VudC52MS5BcnRpZmFjdFVwbG9hZE1ldGFkYXRhIk4KFlVwbG9hZEFydGlmYWN0c1Jl" + + "cXVlc3QSNAoHdXBsb2FkcxgBIAMoCzIjLmFnZW50LnYxLkFydGlmYWN0VXBsb2FkSW5zdHJ1Y3Rp" + + "b24i1wIKGUFydGlmYWN0VXBsb2FkSW5zdHJ1Y3Rpb24SFQoNYWJzb2x1dGVfcGF0aBgBIAEoCRIS" + + "Cgp1cGxvYWRfdXJsGAIgASgJEg4KBm1ldGhvZBgDIAEoCRJBCgdoZWFkZXJzGAQgAygLMjAuYWdl" + + "bnQudjEuQXJ0aWZhY3RVcGxvYWRJbnN0cnVjdGlvbi5IZWFkZXJzRW50cnkSGQoMY29udGVudF90" + + "eXBlGAUgASgJSACIAQESHQoQc2xhY2tfdXBsb2FkX3VybBgGIAEoCUgBiAEBEhoKDXNsYWNrX2Zp" + + "bGVfaWQYByABKAlIAogBARouCgxIZWFkZXJzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIg" + + "ASgJOgI4AUIPCg1fY29udGVudF90eXBlQhMKEV9zbGFja191cGxvYWRfdXJsQhAKDl9zbGFja19m" + + "aWxlX2lkIoQBChxBcnRpZmFjdFVwbG9hZERpc3BhdGNoUmVzdWx0EhUKDWFic29sdXRlX3BhdGgY" + + "ASABKAkSDgoGc3RhdHVzGAIgASgFEg8KB21lc3NhZ2UYAyABKAkSGgoNc2xhY2tfZmlsZV9pZBgE" + + "IAEoCUgAiAEBQhAKDl9zbGFja19maWxlX2lkIlIKF1VwbG9hZEFydGlmYWN0c1Jlc3BvbnNlEjcK" + + "B3Jlc3VsdHMYASADKAsyJi5hZ2VudC52MS5BcnRpZmFjdFVwbG9hZERpc3BhdGNoUmVzdWx0IhwK" + + "GkdldE1jcFJlZnJlc2hUb2tlbnNSZXF1ZXN0IqUBChtHZXRNY3BSZWZyZXNoVG9rZW5zUmVzcG9u" + + "c2USUAoOcmVmcmVzaF90b2tlbnMYASADKAsyOC5hZ2VudC52MS5HZXRNY3BSZWZyZXNoVG9rZW5z" + + "UmVzcG9uc2UuUmVmcmVzaFRva2Vuc0VudHJ5GjQKElJlZnJlc2hUb2tlbnNFbnRyeRILCgNrZXkY" + + "ASABKAkSDQoFdmFsdWUYAiABKAk6AjgBIqMBCiFVcGRhdGVFbnZpcm9ubWVudFZhcmlhYmxlc1Jl" + + "cXVlc3QSQQoDZW52GAEgAygLMjQuYWdlbnQudjEuVXBkYXRlRW52aXJvbm1lbnRWYXJpYWJsZXNS" + + "ZXF1ZXN0LkVudkVudHJ5Eg8KB3JlcGxhY2UYAiABKAgaKgoIRW52RW50cnkSCwoDa2V5GAEgASgJ" + + "Eg0KBXZhbHVlGAIgASgJOgI4ASJGCiJVcGRhdGVFbnZpcm9ubWVudFZhcmlhYmxlc1Jlc3BvbnNl" + + "Eg8KB2FwcGxpZWQYASABKA0SDwoHcmVtb3ZlZBgCIAEoDSKDAQoSTWNwT0F1dGhTdG9yZWREYXRh" + + "EhUKDXJlZnJlc2hfdG9rZW4YASABKAkSEQoJY2xpZW50X2lkGAIgASgJEhoKDWNsaWVudF9zZWNy" + + "ZXQYAyABKAlIAIgBARIVCg1yZWRpcmVjdF91cmlzGAQgAygJQhAKDl9jbGllbnRfc2VjcmV0Ik4K" + + "BUZyYW1lEgoKAmlkGAEgASgJEg4KBm1ldGhvZBgCIAEoCRIMCgRkYXRhGAMgASgMEgwKBGtpbmQY" + + "BCABKAUSDQoFZXJyb3IYBSABKAkiBwoFRW1wdHkiIwoNQmlkaVJlcXVlc3RJZBISCgpyZXF1ZXN0" + + "X2lkGAEgASgJKogBCh1BcHBsaWVkQWdlbnRDaGFuZ2VfQ2hhbmdlVHlwZRIbChdDSEFOR0VfVFlQ" + + "RV9VTlNQRUNJRklFRBAAEhcKE0NIQU5HRV9UWVBFX0NSRUFURUQQARIYChRDSEFOR0VfVFlQRV9N" + + "T0RJRklFRBACEhcKE0NIQU5HRV9UWVBFX0RFTEVURUQQAyqkAQoLTW91c2VCdXR0b24SHAoYTU9V" + + "U0VfQlVUVE9OX1VOU1BFQ0lGSUVEEAASFQoRTU9VU0VfQlVUVE9OX0xFRlQQARIWChJNT1VTRV9C" + + "VVRUT05fUklHSFQQAhIXChNNT1VTRV9CVVRUT05fTUlERExFEAMSFQoRTU9VU0VfQlVUVE9OX0JB" + + "Q0sQBBIYChRNT1VTRV9CVVRUT05fRk9SV0FSRBAFKp4BCg9TY3JvbGxEaXJlY3Rpb24SIAocU0NS" + + "T0xMX0RJUkVDVElPTl9VTlNQRUNJRklFRBAAEhcKE1NDUk9MTF9ESVJFQ1RJT05fVVAQARIZChVT" + + "Q1JPTExfRElSRUNUSU9OX0RPV04QAhIZChVTQ1JPTExfRElSRUNUSU9OX0xFRlQQAxIaChZTQ1JP" + + "TExfRElSRUNUSU9OX1JJR0hUEAQqcAoQQ3Vyc29yUnVsZVNvdXJjZRIiCh5DVVJTT1JfUlVMRV9T" + + "T1VSQ0VfVU5TUEVDSUZJRUQQABIbChdDVVJTT1JfUlVMRV9TT1VSQ0VfVEVBTRABEhsKF0NVUlNP" + + "Ul9SVUxFX1NPVVJDRV9VU0VSEAIqvAEKEkRpYWdub3N0aWNTZXZlcml0eRIjCh9ESUFHTk9TVElD" + + "X1NFVkVSSVRZX1VOU1BFQ0lGSUVEEAASHQoZRElBR05PU1RJQ19TRVZFUklUWV9FUlJPUhABEh8K" + + "G0RJQUdOT1NUSUNfU0VWRVJJVFlfV0FSTklORxACEiMKH0RJQUdOT1NUSUNfU0VWRVJJVFlfSU5G" + + "T1JNQVRJT04QAxIcChhESUFHTk9TVElDX1NFVkVSSVRZX0hJTlQQBCqcAQoNUmVjb3JkaW5nTW9k" + + "ZRIeChpSRUNPUkRJTkdfTU9ERV9VTlNQRUNJRklFRBAAEiIKHlJFQ09SRElOR19NT0RFX1NUQVJU" + + "X1JFQ09SRElORxABEiEKHVJFQ09SRElOR19NT0RFX1NBVkVfUkVDT1JESU5HEAISJAogUkVDT1JE" + + "SU5HX01PREVfRElTQ0FSRF9SRUNPUkRJTkcQAyqTAQofUmVxdWVzdGVkRmlsZVBhdGhSZWplY3Rl" + + "ZFJlYXNvbhIzCi9SRVFVRVNURURfRklMRV9QQVRIX1JFSkVDVEVEX1JFQVNPTl9VTlNQRUNJRklF" + + "RBAAEjsKN1JFUVVFU1RFRF9GSUxFX1BBVEhfUkVKRUNURURfUkVBU09OX1NMQVNIRVNfTk9UX0FM" + + "TE9XRUQQASqtAQoLUGFja2FnZVR5cGUSHAoYUEFDS0FHRV9UWVBFX1VOU1BFQ0lGSUVEEAASHwob" + + "UEFDS0FHRV9UWVBFX0NVUlNPUl9QUk9KRUNUEAESIAocUEFDS0FHRV9UWVBFX0NVUlNPUl9QRVJT" + + "T05BTBACEh0KGVBBQ0tBR0VfVFlQRV9DTEFVREVfU0tJTEwQAxIeChpQQUNLQUdFX1RZUEVfQ0xB" + + "VURFX1BMVUdJThAEKn0KElNhbmRib3hQb2xpY3lfVHlwZRIUChBUWVBFX1VOU1BFQ0lGSUVEEAAS" + + "FgoSVFlQRV9JTlNFQ1VSRV9OT05FEAESHAoYVFlQRV9XT1JLU1BBQ0VfUkVBRFdSSVRFEAISGwoX" + + "VFlQRV9XT1JLU1BBQ0VfUkVBRE9OTFkQAypxCg9UaW1lb3V0QmVoYXZpb3ISIAocVElNRU9VVF9C" + + "RUhBVklPUl9VTlNQRUNJRklFRBAAEhsKF1RJTUVPVVRfQkVIQVZJT1JfQ0FOQ0VMEAESHwobVElN" + + "RU9VVF9CRUhBVklPUl9CQUNLR1JPVU5EEAIqeQoQU2hlbGxBYm9ydFJlYXNvbhIiCh5TSEVMTF9B" + + "Qk9SVF9SRUFTT05fVU5TUEVDSUZJRUQQABIhCh1TSEVMTF9BQk9SVF9SRUFTT05fVVNFUl9BQk9S" + + "VBABEh4KGlNIRUxMX0FCT1JUX1JFQVNPTl9USU1FT1VUEAIqqgEKHEN1c3RvbVN1YmFnZW50UGVy" + + "bWlzc2lvbk1vZGUSLworQ1VTVE9NX1NVQkFHRU5UX1BFUk1JU1NJT05fTU9ERV9VTlNQRUNJRklF" + + "RBAAEisKJ0NVU1RPTV9TVUJBR0VOVF9QRVJNSVNTSU9OX01PREVfREVGQVVMVBABEiwKKENVU1RP" + + "TV9TVUJBR0VOVF9QRVJNSVNTSU9OX01PREVfUkVBRE9OTFkQAiqVAQoKVG9kb1N0YXR1cxIbChdU" + + "T0RPX1NUQVRVU19VTlNQRUNJRklFRBAAEhcKE1RPRE9fU1RBVFVTX1BFTkRJTkcQARIbChdUT0RP" + + "X1NUQVRVU19JTl9QUk9HUkVTUxACEhkKFVRPRE9fU1RBVFVTX0NPTVBMRVRFRBADEhkKFVRPRE9f" + + "U1RBVFVTX0NBTkNFTExFRBAEKmYKCENsaWVudE9TEhkKFUNMSUVOVF9PU19VTlNQRUNJRklFRBAA" + + "EhUKEUNMSUVOVF9PU19XSU5ET1dTEAESEwoPQ0xJRU5UX09TX01BQ09TEAISEwoPQ0xJRU5UX09T" + + "X0xJTlVYEAMq7AEKHEFydGlmYWN0VXBsb2FkRGlzcGF0Y2hTdGF0dXMSLworQVJUSUZBQ1RfVVBM" + + "T0FEX0RJU1BBVENIX1NUQVRVU19VTlNQRUNJRklFRBAAEiwKKEFSVElGQUNUX1VQTE9BRF9ESVNQ" + + "QVRDSF9TVEFUVVNfQUNDRVBURUQQARIsCihBUlRJRkFDVF9VUExPQURfRElTUEFUQ0hfU1RBVFVT" + + "X1JFSkVDVEVEEAISPwo7QVJUSUZBQ1RfVVBMT0FEX0RJU1BBVENIX1NUQVRVU19TS0lQUEVEX0FM" + + "UkVBRFlfSU5fUFJPR1JFU1MQAypXCgpGcmFtZV9LaW5kEhQKEEtJTkRfVU5TUEVDSUZJRUQQABIQ" + + "CgxLSU5EX1JFUVVFU1QQARIRCg1LSU5EX1JFU1BPTlNFEAISDgoKS0lORF9FUlJPUhADKrACChdC" + + "dWdib3REZWVwbGlua0V2ZW50S2luZBIqCiZCVUdCT1RfREVFUExJTktfRVZFTlRfS0lORF9VTlNQ" + + "RUNJRklFRBAAEiYKIkJVR0JPVF9ERUVQTElOS19FVkVOVF9LSU5EX0NMSUNLRUQQARIzCi9CVUdC" + + "T1RfREVFUExJTktfRVZFTlRfS0lORF9IQU5ETEVEX0RJQUxPR19TSE9XThACEjMKL0JVR0JPVF9E" + + "RUVQTElOS19FVkVOVF9LSU5EX0hBTkRMRURfQ0hBVF9DUkVBVEVEEAMSJAogQlVHQk9UX0RFRVBM" + + "SU5LX0VWRU5UX0tJTkRfRVJST1IQBBIxCi1CVUdCT1RfREVFUExJTktfRVZFTlRfS0lORF9IQU5E" + + "TEVEX0ZJWF9JTl9XRUIQBTKHBAoMQWdlbnRTZXJ2aWNlEkEKA1J1bhIcLmFnZW50LnYxLkFnZW50" + + "Q2xpZW50TWVzc2FnZRocLmFnZW50LnYxLkFnZW50U2VydmVyTWVzc2FnZRI/CgZSdW5TU0USFy5h" + + "Z2VudC52MS5CaWRpUmVxdWVzdElkGhwuYWdlbnQudjEuQWdlbnRTZXJ2ZXJNZXNzYWdlEkQKCU5h" + + "bWVBZ2VudBIaLmFnZW50LnYxLk5hbWVBZ2VudFJlcXVlc3QaGy5hZ2VudC52MS5OYW1lQWdlbnRS" + + "ZXNwb25zZRJWCg9HZXRVc2FibGVNb2RlbHMSIC5hZ2VudC52MS5HZXRVc2FibGVNb2RlbHNSZXF1" + + "ZXN0GiEuYWdlbnQudjEuR2V0VXNhYmxlTW9kZWxzUmVzcG9uc2USaAoVR2V0RGVmYXVsdE1vZGVs" + + "Rm9yQ2xpEiYuYWdlbnQudjEuR2V0RGVmYXVsdE1vZGVsRm9yQ2xpUmVxdWVzdBonLmFnZW50LnYx" + + "LkdldERlZmF1bHRNb2RlbEZvckNsaVJlc3BvbnNlEmsKFkdldEFsbG93ZWRNb2RlbEludGVudHMS" + + "Jy5hZ2VudC52MS5HZXRBbGxvd2VkTW9kZWxJbnRlbnRzUmVxdWVzdBooLmFnZW50LnYxLkdldEFs" + + "bG93ZWRNb2RlbEludGVudHNSZXNwb25zZTK1CAoOQ29udHJvbFNlcnZpY2USTQoMUmVhZFRleHRG" + + "aWxlEh0uYWdlbnQudjEuUmVhZFRleHRGaWxlUmVxdWVzdBoeLmFnZW50LnYxLlJlYWRUZXh0Rmls" + + "ZVJlc3BvbnNlElAKDVdyaXRlVGV4dEZpbGUSHi5hZ2VudC52MS5Xcml0ZVRleHRGaWxlUmVxdWVz" + + "dBofLmFnZW50LnYxLldyaXRlVGV4dEZpbGVSZXNwb25zZRJTCg5SZWFkQmluYXJ5RmlsZRIfLmFn" + + "ZW50LnYxLlJlYWRCaW5hcnlGaWxlUmVxdWVzdBogLmFnZW50LnYxLlJlYWRCaW5hcnlGaWxlUmVz" + + "cG9uc2USVgoPV3JpdGVCaW5hcnlGaWxlEiAuYWdlbnQudjEuV3JpdGVCaW5hcnlGaWxlUmVxdWVz" + + "dBohLmFnZW50LnYxLldyaXRlQmluYXJ5RmlsZVJlc3BvbnNlEm4KF0dldFdvcmtzcGFjZUNoYW5n" + + "ZXNIYXNoEiguYWdlbnQudjEuR2V0V29ya3NwYWNlQ2hhbmdlc0hhc2hSZXF1ZXN0GikuYWdlbnQu" + + "djEuR2V0V29ya3NwYWNlQ2hhbmdlc0hhc2hSZXNwb25zZRJxChhSZWZyZXNoR2l0aHViQWNjZXNz" + + "VG9rZW4SKS5hZ2VudC52MS5SZWZyZXNoR2l0aHViQWNjZXNzVG9rZW5SZXF1ZXN0GiouYWdlbnQu" + + "djEuUmVmcmVzaEdpdGh1YkFjY2Vzc1Rva2VuUmVzcG9uc2USawoWV2FybVJlbW90ZUFjY2Vzc1Nl" + + "cnZlchInLmFnZW50LnYxLldhcm1SZW1vdGVBY2Nlc3NTZXJ2ZXJSZXF1ZXN0GiguYWdlbnQudjEu" + + "V2FybVJlbW90ZUFjY2Vzc1NlcnZlclJlc3BvbnNlElAKDUxpc3RBcnRpZmFjdHMSHi5hZ2VudC52" + + "MS5MaXN0QXJ0aWZhY3RzUmVxdWVzdBofLmFnZW50LnYxLkxpc3RBcnRpZmFjdHNSZXNwb25zZRJW" + + "Cg9VcGxvYWRBcnRpZmFjdHMSIC5hZ2VudC52MS5VcGxvYWRBcnRpZmFjdHNSZXF1ZXN0GiEuYWdl" + + "bnQudjEuVXBsb2FkQXJ0aWZhY3RzUmVzcG9uc2USYgoTR2V0TWNwUmVmcmVzaFRva2VucxIkLmFn" + + "ZW50LnYxLkdldE1jcFJlZnJlc2hUb2tlbnNSZXF1ZXN0GiUuYWdlbnQudjEuR2V0TWNwUmVmcmVz" + + "aFRva2Vuc1Jlc3BvbnNlEncKGlVwZGF0ZUVudmlyb25tZW50VmFyaWFibGVzEisuYWdlbnQudjEu" + + "VXBkYXRlRW52aXJvbm1lbnRWYXJpYWJsZXNSZXF1ZXN0GiwuYWdlbnQudjEuVXBkYXRlRW52aXJv" + + "bm1lbnRWYXJpYWJsZXNSZXNwb25zZTINCgtFeGVjU2VydmljZTJRCiJQcml2YXRlV29ya2VyQnJp" + + "ZGdlRXh0ZXJuYWxTZXJ2aWNlEisKB0Nvbm5lY3QSDy5hZ2VudC52MS5GcmFtZRoPLmFnZW50LnYx" + + "LkZyYW1lMngKEExpZmVjeWNsZVNlcnZpY2USMQoNUmVzZXRJbnN0YW5jZRIPLmFnZW50LnYxLkVt" + + "cHR5Gg8uYWdlbnQudjEuRW1wdHkSMQoNUmVuZXdJbnN0YW5jZRIPLmFnZW50LnYxLkVtcHR5Gg8u" + + "YWdlbnQudjEuRW1wdHliBnByb3RvMw==" + +var ( + fileDescOnce sync.Once + fileDesc protoreflect.FileDescriptor +) + +// AgentFileDescriptor returns the parsed FileDescriptor for agent.proto. +func AgentFileDescriptor() protoreflect.FileDescriptor { + fileDescOnce.Do(func() { + raw, err := base64.StdEncoding.DecodeString(agentDescriptorB64) + if err != nil { + panic("cursor proto: failed to decode descriptor: " + err.Error()) + } + fdp := &descrptorpb.FileDescriptorProto{} + if err := proto.Unmarshal(raw, fdp); err != nil { + panic("cursor proto: failed to unmarshal descriptor: " + err.Error()) + } + fd, err := protodesc.NewFile(fdp, nil) + if err != nil { + panic("cursor proto: failed to create file descriptor: " + err.Error()) + } + fileDesc = fd + }) + return fileDesc +} + +// Msg returns the MessageDescriptor for a top-level message by name. +func Msg(name string) protoreflect.MessageDescriptor { + md := AgentFileDescriptor().Messages().ByName(protoreflect.Name(name)) + if md == nil { + panic("cursor proto: message not found: " + name) + } + return md +} diff --git a/internal/auth/cursor/proto/encode.go b/internal/auth/cursor/proto/encode.go new file mode 100644 index 00000000..8ee2910f --- /dev/null +++ b/internal/auth/cursor/proto/encode.go @@ -0,0 +1,491 @@ +// Package proto provides protobuf encoding for Cursor's gRPC API, +// using dynamicpb with the embedded FileDescriptorProto from agent.proto. +// This mirrors the cursor-auth TS plugin's use of @bufbuild/protobuf create()+toBinary(). +package proto + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + + log "github.com/sirupsen/logrus" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/types/dynamicpb" + "google.golang.org/protobuf/types/known/structpb" +) + +// --- Public types --- + +// RunRequestParams holds all data needed to build an AgentRunRequest. +type RunRequestParams struct { + ModelId string + SystemPrompt string + UserText string + MessageId string + ConversationId string + Images []ImageData + Turns []TurnData + McpTools []McpToolDef + BlobStore map[string][]byte // hex(sha256) -> data, populated during encoding +} + +type ImageData struct { + MimeType string + Data []byte +} + +type TurnData struct { + UserText string + AssistantText string +} + +type McpToolDef struct { + Name string + Description string + InputSchema json.RawMessage +} + +// --- Helper: create a dynamic message and set fields --- + +func newMsg(name string) *dynamicpb.Message { + return dynamicpb.NewMessage(Msg(name)) +} + +func field(msg *dynamicpb.Message, name string) protoreflect.FieldDescriptor { + return msg.Descriptor().Fields().ByName(protoreflect.Name(name)) +} + +func setStr(msg *dynamicpb.Message, name, val string) { + if val != "" { + msg.Set(field(msg, name), protoreflect.ValueOfString(val)) + } +} + +func setBytes(msg *dynamicpb.Message, name string, val []byte) { + if len(val) > 0 { + msg.Set(field(msg, name), protoreflect.ValueOfBytes(val)) + } +} + +func setUint32(msg *dynamicpb.Message, name string, val uint32) { + msg.Set(field(msg, name), protoreflect.ValueOfUint32(val)) +} + +func setBool(msg *dynamicpb.Message, name string, val bool) { + msg.Set(field(msg, name), protoreflect.ValueOfBool(val)) +} + +func setMsg(msg *dynamicpb.Message, name string, sub *dynamicpb.Message) { + msg.Set(field(msg, name), protoreflect.ValueOfMessage(sub.ProtoReflect())) +} + +func marshal(msg *dynamicpb.Message) []byte { + b, err := proto.Marshal(msg) + if err != nil { + panic("cursor proto marshal: " + err.Error()) + } + return b +} + +// --- Encode functions mirroring cursor-fetch.ts --- + +// EncodeHeartbeat returns an encoded AgentClientMessage with clientHeartbeat. +// Mirrors: create(AgentClientMessageSchema, { message: { case: 'clientHeartbeat', value: create(ClientHeartbeatSchema, {}) } }) +func EncodeHeartbeat() []byte { + hb := newMsg("ClientHeartbeat") + acm := newMsg("AgentClientMessage") + setMsg(acm, "client_heartbeat", hb) + return marshal(acm) +} + +// EncodeRunRequest builds a full AgentClientMessage wrapping an AgentRunRequest. +// Mirrors buildCursorRequest() in cursor-fetch.ts. +func EncodeRunRequest(p *RunRequestParams) []byte { + if p.BlobStore == nil { + p.BlobStore = make(map[string][]byte) + } + + // --- Conversation turns --- + // Each turn is serialized as bytes (ConversationTurnStructure → bytes) + var turnBytes [][]byte + for _, turn := range p.Turns { + // UserMessage for this turn + um := newMsg("UserMessage") + setStr(um, "text", turn.UserText) + setStr(um, "message_id", generateId()) + umBytes := marshal(um) + + // Steps (assistant response) + var stepBytes [][]byte + if turn.AssistantText != "" { + am := newMsg("AssistantMessage") + setStr(am, "text", turn.AssistantText) + step := newMsg("ConversationStep") + setMsg(step, "assistant_message", am) + stepBytes = append(stepBytes, marshal(step)) + } + + // AgentConversationTurnStructure (fields are bytes, not submessages) + agentTurn := newMsg("AgentConversationTurnStructure") + setBytes(agentTurn, "user_message", umBytes) + for _, sb := range stepBytes { + stepsField := field(agentTurn, "steps") + list := agentTurn.Mutable(stepsField).List() + list.Append(protoreflect.ValueOfBytes(sb)) + } + + // ConversationTurnStructure (oneof turn → agentConversationTurn) + cts := newMsg("ConversationTurnStructure") + setMsg(cts, "agent_conversation_turn", agentTurn) + turnBytes = append(turnBytes, marshal(cts)) + } + + // --- System prompt blob --- + systemJSON, _ := json.Marshal(map[string]string{"role": "system", "content": p.SystemPrompt}) + blobId := sha256Sum(systemJSON) + p.BlobStore[hex.EncodeToString(blobId)] = systemJSON + + // --- ConversationStateStructure --- + css := newMsg("ConversationStateStructure") + // rootPromptMessagesJson: repeated bytes + rootField := field(css, "root_prompt_messages_json") + rootList := css.Mutable(rootField).List() + rootList.Append(protoreflect.ValueOfBytes(blobId)) + // turns: repeated bytes + turnsField := field(css, "turns") + turnsList := css.Mutable(turnsField).List() + for _, tb := range turnBytes { + turnsList.Append(protoreflect.ValueOfBytes(tb)) + } + + // --- UserMessage (current) --- + userMessage := newMsg("UserMessage") + setStr(userMessage, "text", p.UserText) + setStr(userMessage, "message_id", p.MessageId) + + // Images via SelectedContext + if len(p.Images) > 0 { + sc := newMsg("SelectedContext") + imgsField := field(sc, "selected_images") + imgsList := sc.Mutable(imgsField).List() + for _, img := range p.Images { + si := newMsg("SelectedImage") + setStr(si, "uuid", generateId()) + setStr(si, "mime_type", img.MimeType) + setBytes(si, "data", img.Data) + imgsList.Append(protoreflect.ValueOfMessage(si.ProtoReflect())) + } + setMsg(userMessage, "selected_context", sc) + } + + // --- UserMessageAction --- + uma := newMsg("UserMessageAction") + setMsg(uma, "user_message", userMessage) + + // --- ConversationAction --- + ca := newMsg("ConversationAction") + setMsg(ca, "user_message_action", uma) + + // --- ModelDetails --- + md := newMsg("ModelDetails") + setStr(md, "model_id", p.ModelId) + setStr(md, "display_model_id", p.ModelId) + setStr(md, "display_name", p.ModelId) + + // --- AgentRunRequest --- + arr := newMsg("AgentRunRequest") + setMsg(arr, "conversation_state", css) + setMsg(arr, "action", ca) + setMsg(arr, "model_details", md) + setStr(arr, "conversation_id", p.ConversationId) + + // McpTools + if len(p.McpTools) > 0 { + mcpTools := newMsg("McpTools") + toolsField := field(mcpTools, "mcp_tools") + toolsList := mcpTools.Mutable(toolsField).List() + for _, tool := range p.McpTools { + td := newMsg("McpToolDefinition") + setStr(td, "name", tool.Name) + setStr(td, "description", tool.Description) + if len(tool.InputSchema) > 0 { + setBytes(td, "input_schema", jsonToProtobufValueBytes(tool.InputSchema)) + } + setStr(td, "provider_identifier", "proxy") + setStr(td, "tool_name", tool.Name) + toolsList.Append(protoreflect.ValueOfMessage(td.ProtoReflect())) + } + setMsg(arr, "mcp_tools", mcpTools) + } + + // --- AgentClientMessage --- + acm := newMsg("AgentClientMessage") + setMsg(acm, "run_request", arr) + + return marshal(acm) +} + +// --- KV response encoders --- +// Mirrors handleKvMessage() in cursor-fetch.ts + +// EncodeKvGetBlobResult responds to a getBlobArgs request. +func EncodeKvGetBlobResult(kvId uint32, blobData []byte) []byte { + result := newMsg("GetBlobResult") + if blobData != nil { + setBytes(result, "blob_data", blobData) + } + + kvc := newMsg("KvClientMessage") + setUint32(kvc, "id", kvId) + setMsg(kvc, "get_blob_result", result) + + acm := newMsg("AgentClientMessage") + setMsg(acm, "kv_client_message", kvc) + return marshal(acm) +} + +// EncodeKvSetBlobResult responds to a setBlobArgs request. +func EncodeKvSetBlobResult(kvId uint32) []byte { + result := newMsg("SetBlobResult") + + kvc := newMsg("KvClientMessage") + setUint32(kvc, "id", kvId) + setMsg(kvc, "set_blob_result", result) + + acm := newMsg("AgentClientMessage") + setMsg(acm, "kv_client_message", kvc) + return marshal(acm) +} + +// --- Exec response encoders --- +// Mirrors handleExecMessage() and sendExec() in cursor-fetch.ts + +// EncodeExecRequestContextResult responds to requestContextArgs with tool definitions. +func EncodeExecRequestContextResult(execMsgId uint32, execId string, tools []McpToolDef) []byte { + // RequestContext with tools + rc := newMsg("RequestContext") + if len(tools) > 0 { + toolsField := field(rc, "tools") + toolsList := rc.Mutable(toolsField).List() + for _, tool := range tools { + td := newMsg("McpToolDefinition") + setStr(td, "name", tool.Name) + setStr(td, "description", tool.Description) + if len(tool.InputSchema) > 0 { + setBytes(td, "input_schema", jsonToProtobufValueBytes(tool.InputSchema)) + } + setStr(td, "provider_identifier", "proxy") + setStr(td, "tool_name", tool.Name) + toolsList.Append(protoreflect.ValueOfMessage(td.ProtoReflect())) + } + } + + // RequestContextSuccess + rcs := newMsg("RequestContextSuccess") + setMsg(rcs, "request_context", rc) + + // RequestContextResult (oneof success) + rcr := newMsg("RequestContextResult") + setMsg(rcr, "success", rcs) + + return encodeExecClientMsg(execMsgId, execId, "request_context_result", rcr) +} + +// EncodeExecMcpResult responds with MCP tool result. +func EncodeExecMcpResult(execMsgId uint32, execId string, content string, isError bool) []byte { + textContent := newMsg("McpTextContent") + setStr(textContent, "text", content) + + contentItem := newMsg("McpToolResultContentItem") + setMsg(contentItem, "text", textContent) + + success := newMsg("McpSuccess") + contentField := field(success, "content") + contentList := success.Mutable(contentField).List() + contentList.Append(protoreflect.ValueOfMessage(contentItem.ProtoReflect())) + setBool(success, "is_error", isError) + + result := newMsg("McpResult") + setMsg(result, "success", success) + + return encodeExecClientMsg(execMsgId, execId, "mcp_result", result) +} + +// EncodeExecMcpError responds with MCP error. +func EncodeExecMcpError(execMsgId uint32, execId string, errMsg string) []byte { + mcpErr := newMsg("McpError") + setStr(mcpErr, "error", errMsg) + + result := newMsg("McpResult") + setMsg(result, "error", mcpErr) + + return encodeExecClientMsg(execMsgId, execId, "mcp_result", result) +} + +// --- Rejection encoders (mirror handleExecMessage rejections) --- + +func EncodeExecReadRejected(execMsgId uint32, execId string, path, reason string) []byte { + rej := newMsg("ReadRejected") + setStr(rej, "path", path) + setStr(rej, "reason", reason) + result := newMsg("ReadResult") + setMsg(result, "rejected", rej) + return encodeExecClientMsg(execMsgId, execId, "read_result", result) +} + +func EncodeExecShellRejected(execMsgId uint32, execId string, command, workDir, reason string) []byte { + rej := newMsg("ShellRejected") + setStr(rej, "command", command) + setStr(rej, "working_directory", workDir) + setStr(rej, "reason", reason) + result := newMsg("ShellResult") + setMsg(result, "rejected", rej) + return encodeExecClientMsg(execMsgId, execId, "shell_result", result) +} + +func EncodeExecWriteRejected(execMsgId uint32, execId string, path, reason string) []byte { + rej := newMsg("WriteRejected") + setStr(rej, "path", path) + setStr(rej, "reason", reason) + result := newMsg("WriteResult") + setMsg(result, "rejected", rej) + return encodeExecClientMsg(execMsgId, execId, "write_result", result) +} + +func EncodeExecDeleteRejected(execMsgId uint32, execId string, path, reason string) []byte { + rej := newMsg("DeleteRejected") + setStr(rej, "path", path) + setStr(rej, "reason", reason) + result := newMsg("DeleteResult") + setMsg(result, "rejected", rej) + return encodeExecClientMsg(execMsgId, execId, "delete_result", result) +} + +func EncodeExecLsRejected(execMsgId uint32, execId string, path, reason string) []byte { + rej := newMsg("LsRejected") + setStr(rej, "path", path) + setStr(rej, "reason", reason) + result := newMsg("LsResult") + setMsg(result, "rejected", rej) + return encodeExecClientMsg(execMsgId, execId, "ls_result", result) +} + +func EncodeExecGrepError(execMsgId uint32, execId string, errMsg string) []byte { + grepErr := newMsg("GrepError") + setStr(grepErr, "error", errMsg) + result := newMsg("GrepResult") + setMsg(result, "error", grepErr) + return encodeExecClientMsg(execMsgId, execId, "grep_result", result) +} + +func EncodeExecFetchError(execMsgId uint32, execId string, url, errMsg string) []byte { + fetchErr := newMsg("FetchError") + setStr(fetchErr, "url", url) + setStr(fetchErr, "error", errMsg) + result := newMsg("FetchResult") + setMsg(result, "error", fetchErr) + return encodeExecClientMsg(execMsgId, execId, "fetch_result", result) +} + +func EncodeExecDiagnosticsResult(execMsgId uint32, execId string) []byte { + result := newMsg("DiagnosticsResult") + return encodeExecClientMsg(execMsgId, execId, "diagnostics_result", result) +} + +func EncodeExecBackgroundShellSpawnRejected(execMsgId uint32, execId string, command, workDir, reason string) []byte { + rej := newMsg("ShellRejected") + setStr(rej, "command", command) + setStr(rej, "working_directory", workDir) + setStr(rej, "reason", reason) + result := newMsg("BackgroundShellSpawnResult") + setMsg(result, "rejected", rej) + return encodeExecClientMsg(execMsgId, execId, "background_shell_spawn_result", result) +} + +func EncodeExecWriteShellStdinError(execMsgId uint32, execId string, errMsg string) []byte { + wsErr := newMsg("WriteShellStdinError") + setStr(wsErr, "error", errMsg) + result := newMsg("WriteShellStdinResult") + setMsg(result, "error", wsErr) + return encodeExecClientMsg(execMsgId, execId, "write_shell_stdin_result", result) +} + +// encodeExecClientMsg wraps an exec result in AgentClientMessage. +// Mirrors sendExec() in cursor-fetch.ts. +func encodeExecClientMsg(id uint32, execId string, resultFieldName string, resultMsg *dynamicpb.Message) []byte { + ecm := newMsg("ExecClientMessage") + setUint32(ecm, "id", id) + // Force set exec_id even if empty - Cursor requires this field to be set + ecm.Set(field(ecm, "exec_id"), protoreflect.ValueOfString(execId)) + + // Debug: check if field exists + fd := field(ecm, resultFieldName) + if fd == nil { + panic(fmt.Sprintf("field %q NOT FOUND in ExecClientMessage! Available fields: %v", resultFieldName, listFields(ecm))) + } + + // Debug: log the actual field being set + log.Debugf("encodeExecClientMsg: setting field %q (number=%d, kind=%s)", fd.Name(), fd.Number(), fd.Kind()) + + ecm.Set(fd, protoreflect.ValueOfMessage(resultMsg.ProtoReflect())) + + acm := newMsg("AgentClientMessage") + setMsg(acm, "exec_client_message", ecm) + return marshal(acm) +} + +func listFields(msg *dynamicpb.Message) []string { + var names []string + for i := 0; i < msg.Descriptor().Fields().Len(); i++ { + names = append(names, string(msg.Descriptor().Fields().Get(i).Name())) + } + return names +} + +// --- Utilities --- + +// jsonToProtobufValueBytes converts a JSON schema (json.RawMessage) to protobuf Value binary. +// This mirrors the TS pattern: toBinary(ValueSchema, fromJson(ValueSchema, jsonSchema)) +func jsonToProtobufValueBytes(jsonData json.RawMessage) []byte { + if len(jsonData) == 0 { + return nil + } + var v interface{} + if err := json.Unmarshal(jsonData, &v); err != nil { + return jsonData // fallback to raw JSON if parsing fails + } + pbVal, err := structpb.NewValue(v) + if err != nil { + return jsonData // fallback + } + b, err := proto.Marshal(pbVal) + if err != nil { + return jsonData // fallback + } + return b +} + +// ProtobufValueBytesToJSON converts protobuf Value binary back to JSON. +// This mirrors the TS pattern: toJson(ValueSchema, fromBinary(ValueSchema, value)) +func ProtobufValueBytesToJSON(data []byte) (interface{}, error) { + val := &structpb.Value{} + if err := proto.Unmarshal(data, val); err != nil { + return nil, err + } + return val.AsInterface(), nil +} + +func sha256Sum(data []byte) []byte { + h := sha256.Sum256(data) + return h[:] +} + +var idCounter uint64 + +func generateId() string { + idCounter++ + h := sha256.Sum256([]byte{byte(idCounter), byte(idCounter >> 8), byte(idCounter >> 16)}) + return hex.EncodeToString(h[:16]) +} diff --git a/internal/auth/cursor/proto/fieldnumbers.go b/internal/auth/cursor/proto/fieldnumbers.go new file mode 100644 index 00000000..7ba24109 --- /dev/null +++ b/internal/auth/cursor/proto/fieldnumbers.go @@ -0,0 +1,332 @@ +// Package proto provides hand-rolled protobuf encode/decode for Cursor's gRPC API. +// Field numbers are extracted from the TypeScript generated proto/agent_pb.ts in alma-plugins/cursor-auth. +package proto + +// AgentClientMessage (msg 118) oneof "message" +const ( + ACM_RunRequest = 1 // AgentRunRequest + ACM_ExecClientMessage = 2 // ExecClientMessage + ACM_KvClientMessage = 3 // KvClientMessage + ACM_ConversationAction = 4 // ConversationAction + ACM_ExecClientControlMsg = 5 // ExecClientControlMessage + ACM_InteractionResponse = 6 // InteractionResponse + ACM_ClientHeartbeat = 7 // ClientHeartbeat +) + +// AgentServerMessage (msg 119) oneof "message" +const ( + ASM_InteractionUpdate = 1 // InteractionUpdate + ASM_ExecServerMessage = 2 // ExecServerMessage + ASM_ConversationCheckpoint = 3 // ConversationStateStructure + ASM_KvServerMessage = 4 // KvServerMessage + ASM_ExecServerControlMessage = 5 // ExecServerControlMessage + ASM_InteractionQuery = 7 // InteractionQuery +) + +// AgentRunRequest (msg 91) +const ( + ARR_ConversationState = 1 // ConversationStateStructure + ARR_Action = 2 // ConversationAction + ARR_ModelDetails = 3 // ModelDetails + ARR_McpTools = 4 // McpTools + ARR_ConversationId = 5 // string (optional) +) + +// ConversationStateStructure (msg 83) +const ( + CSS_RootPromptMessagesJson = 1 // repeated bytes + CSS_TurnsOld = 2 // repeated bytes (deprecated) + CSS_Todos = 3 // repeated bytes + CSS_PendingToolCalls = 4 // repeated string + CSS_Turns = 8 // repeated bytes (CURRENT field for turns) + CSS_PreviousWorkspaceUris = 9 // repeated string + CSS_SelfSummaryCount = 17 // uint32 + CSS_ReadPaths = 18 // repeated string +) + +// ConversationAction (msg 54) oneof "action" +const ( + CA_UserMessageAction = 1 // UserMessageAction +) + +// UserMessageAction (msg 55) +const ( + UMA_UserMessage = 1 // UserMessage +) + +// UserMessage (msg 63) +const ( + UM_Text = 1 // string + UM_MessageId = 2 // string + UM_SelectedContext = 3 // SelectedContext (optional) +) + +// SelectedContext +const ( + SC_SelectedImages = 1 // repeated SelectedImage +) + +// SelectedImage +const ( + SI_BlobId = 1 // bytes (oneof dataOrBlobId) + SI_Uuid = 2 // string + SI_Path = 3 // string + SI_MimeType = 7 // string + SI_Data = 8 // bytes (oneof dataOrBlobId) +) + +// ModelDetails (msg 88) +const ( + MD_ModelId = 1 // string + MD_ThinkingDetails = 2 // ThinkingDetails (optional) + MD_DisplayModelId = 3 // string + MD_DisplayName = 4 // string +) + +// McpTools (msg 307) +const ( + MT_McpTools = 1 // repeated McpToolDefinition +) + +// McpToolDefinition (msg 306) +const ( + MTD_Name = 1 // string + MTD_Description = 2 // string + MTD_InputSchema = 3 // bytes + MTD_ProviderIdentifier = 4 // string + MTD_ToolName = 5 // string +) + +// ConversationTurnStructure (msg 70) oneof "turn" +const ( + CTS_AgentConversationTurn = 1 // AgentConversationTurnStructure +) + +// AgentConversationTurnStructure (msg 72) +const ( + ACTS_UserMessage = 1 // bytes (serialized UserMessage) + ACTS_Steps = 2 // repeated bytes (serialized ConversationStep) +) + +// ConversationStep (msg 53) oneof "message" +const ( + CS_AssistantMessage = 1 // AssistantMessage +) + +// AssistantMessage +const ( + AM_Text = 1 // string +) + +// --- Server-side message fields --- + +// InteractionUpdate oneof "message" +const ( + IU_TextDelta = 1 // TextDeltaUpdate + IU_ThinkingDelta = 4 // ThinkingDeltaUpdate + IU_ThinkingCompleted = 5 // ThinkingCompletedUpdate +) + +// TextDeltaUpdate (msg 92) +const ( + TDU_Text = 1 // string +) + +// ThinkingDeltaUpdate (msg 97) +const ( + TKD_Text = 1 // string +) + +// KvServerMessage (msg 271) +const ( + KSM_Id = 1 // uint32 + KSM_GetBlobArgs = 2 // GetBlobArgs + KSM_SetBlobArgs = 3 // SetBlobArgs +) + +// GetBlobArgs (msg 267) +const ( + GBA_BlobId = 1 // bytes +) + +// SetBlobArgs (msg 269) +const ( + SBA_BlobId = 1 // bytes + SBA_BlobData = 2 // bytes +) + +// KvClientMessage (msg 272) +const ( + KCM_Id = 1 // uint32 + KCM_GetBlobResult = 2 // GetBlobResult + KCM_SetBlobResult = 3 // SetBlobResult +) + +// GetBlobResult (msg 268) +const ( + GBR_BlobData = 1 // bytes (optional) +) + +// ExecServerMessage +const ( + ESM_Id = 1 // uint32 + ESM_ExecId = 15 // string + // oneof message: + ESM_ShellArgs = 2 // ShellArgs + ESM_WriteArgs = 3 // WriteArgs + ESM_DeleteArgs = 4 // DeleteArgs + ESM_GrepArgs = 5 // GrepArgs + ESM_ReadArgs = 7 // ReadArgs (NOTE: 6 is skipped) + ESM_LsArgs = 8 // LsArgs + ESM_DiagnosticsArgs = 9 // DiagnosticsArgs + ESM_RequestContextArgs = 10 // RequestContextArgs + ESM_McpArgs = 11 // McpArgs + ESM_ShellStreamArgs = 14 // ShellArgs (stream variant) + ESM_BackgroundShellSpawn = 16 // BackgroundShellSpawnArgs + ESM_FetchArgs = 20 // FetchArgs + ESM_WriteShellStdinArgs = 23 // WriteShellStdinArgs +) + +// ExecClientMessage +const ( + ECM_Id = 1 // uint32 + ECM_ExecId = 15 // string + // oneof message (mirrors server fields): + ECM_ShellResult = 2 + ECM_WriteResult = 3 + ECM_DeleteResult = 4 + ECM_GrepResult = 5 + ECM_ReadResult = 7 + ECM_LsResult = 8 + ECM_DiagnosticsResult = 9 + ECM_RequestContextResult = 10 + ECM_McpResult = 11 + ECM_ShellStream = 14 + ECM_BackgroundShellSpawnRes = 16 + ECM_FetchResult = 20 + ECM_WriteShellStdinResult = 23 +) + +// McpArgs +const ( + MCA_Name = 1 // string + MCA_Args = 2 // map + MCA_ToolCallId = 3 // string + MCA_ProviderIdentifier = 4 // string + MCA_ToolName = 5 // string +) + +// RequestContextResult oneof "result" +const ( + RCR_Success = 1 // RequestContextSuccess + RCR_Error = 2 // RequestContextError +) + +// RequestContextSuccess (msg 337) +const ( + RCS_RequestContext = 1 // RequestContext +) + +// RequestContext +const ( + RC_Rules = 2 // repeated CursorRule + RC_Tools = 7 // repeated McpToolDefinition +) + +// McpResult oneof "result" +const ( + MCR_Success = 1 // McpSuccess + MCR_Error = 2 // McpError + MCR_Rejected = 3 // McpRejected +) + +// McpSuccess (msg 290) +const ( + MCS_Content = 1 // repeated McpToolResultContentItem + MCS_IsError = 2 // bool +) + +// McpToolResultContentItem oneof "content" +const ( + MTRCI_Text = 1 // McpTextContent +) + +// McpTextContent (msg 287) +const ( + MTC_Text = 1 // string +) + +// McpError (msg 291) +const ( + MCE_Error = 1 // string +) + +// --- Rejection messages --- + +// ReadRejected: path=1, reason=2 +// ShellRejected: command=1, workingDirectory=2, reason=3, isReadonly=4 +// WriteRejected: path=1, reason=2 +// DeleteRejected: path=1, reason=2 +// LsRejected: path=1, reason=2 +// GrepError: error=1 +// FetchError: url=1, error=2 +// WriteShellStdinError: error=1 + +// ReadResult oneof: success=1, error=2, rejected=3 +// ShellResult oneof: success=1 (+ various), rejected=? +// The TS code uses specific result field numbers from the oneof: +const ( + RR_Rejected = 3 // ReadResult.rejected + SR_Rejected = 5 // ShellResult.rejected (from TS: ShellResult has success/various/rejected) + WR_Rejected = 5 // WriteResult.rejected + DR_Rejected = 3 // DeleteResult.rejected + LR_Rejected = 3 // LsResult.rejected + GR_Error = 2 // GrepResult.error + FR_Error = 2 // FetchResult.error + BSSR_Rejected = 2 // BackgroundShellSpawnResult.rejected (error field) + WSSR_Error = 2 // WriteShellStdinResult.error +) + +// --- Rejection struct fields --- +const ( + REJ_Path = 1 + REJ_Reason = 2 + SREJ_Command = 1 + SREJ_WorkingDir = 2 + SREJ_Reason = 3 + SREJ_IsReadonly = 4 + GERR_Error = 1 + FERR_Url = 1 + FERR_Error = 2 +) + +// ReadArgs +const ( + RA_Path = 1 // string +) + +// WriteArgs +const ( + WA_Path = 1 // string +) + +// DeleteArgs +const ( + DA_Path = 1 // string +) + +// LsArgs +const ( + LA_Path = 1 // string +) + +// ShellArgs +const ( + SHA_Command = 1 // string + SHA_WorkingDirectory = 2 // string +) + +// FetchArgs +const ( + FA_Url = 1 // string +) diff --git a/internal/auth/cursor/proto/h2stream.go b/internal/auth/cursor/proto/h2stream.go new file mode 100644 index 00000000..d08d099e --- /dev/null +++ b/internal/auth/cursor/proto/h2stream.go @@ -0,0 +1,273 @@ +package proto + +import ( + "crypto/tls" + "fmt" + "io" + "net" + "sync" + "time" + + log "github.com/sirupsen/logrus" + "golang.org/x/net/http2" + "golang.org/x/net/http2/hpack" +) + +// H2Stream provides bidirectional HTTP/2 streaming for the Connect protocol. +// Go's net/http does not support full-duplex HTTP/2, so we use the low-level framer. +type H2Stream struct { + framer *http2.Framer + conn net.Conn + streamID uint32 + mu sync.Mutex + id string // unique identifier for debugging + frameNum int64 // sequential frame counter for debugging + + dataCh chan []byte + doneCh chan struct{} + err error +} + +// ID returns the unique identifier for this stream (for logging). +func (s *H2Stream) ID() string { return s.id } + +// FrameNum returns the current frame number for debugging. +func (s *H2Stream) FrameNum() int64 { + s.mu.Lock() + defer s.mu.Unlock() + return s.frameNum +} + +// DialH2Stream establishes a TLS+HTTP/2 connection and opens a new stream. +func DialH2Stream(host string, headers map[string]string) (*H2Stream, error) { + tlsConn, err := tls.Dial("tcp", host+":443", &tls.Config{ + NextProtos: []string{"h2"}, + }) + if err != nil { + return nil, fmt.Errorf("h2: TLS dial failed: %w", err) + } + if tlsConn.ConnectionState().NegotiatedProtocol != "h2" { + tlsConn.Close() + return nil, fmt.Errorf("h2: server did not negotiate h2") + } + + framer := http2.NewFramer(tlsConn, tlsConn) + + // Client connection preface + if _, err := tlsConn.Write([]byte(http2.ClientPreface)); err != nil { + tlsConn.Close() + return nil, fmt.Errorf("h2: preface write failed: %w", err) + } + + // Send initial SETTINGS (with large initial window) + if err := framer.WriteSettings( + http2.Setting{ID: http2.SettingInitialWindowSize, Val: 4 * 1024 * 1024}, + http2.Setting{ID: http2.SettingMaxConcurrentStreams, Val: 100}, + ); err != nil { + tlsConn.Close() + return nil, fmt.Errorf("h2: settings write failed: %w", err) + } + + // Connection-level window update (default is 65535, bump it up) + if err := framer.WriteWindowUpdate(0, 3*1024*1024); err != nil { + tlsConn.Close() + return nil, fmt.Errorf("h2: window update failed: %w", err) + } + + // Read and handle initial server frames (SETTINGS, WINDOW_UPDATE) + for i := 0; i < 5; i++ { + f, err := framer.ReadFrame() + if err != nil { + tlsConn.Close() + return nil, fmt.Errorf("h2: initial frame read failed: %w", err) + } + switch sf := f.(type) { + case *http2.SettingsFrame: + if !sf.IsAck() { + framer.WriteSettingsAck() + } else { + goto handshakeDone + } + case *http2.WindowUpdateFrame: + // ignore + default: + // unexpected but continue + } + } +handshakeDone: + + // Build HEADERS + streamID := uint32(1) + var hdrBuf []byte + enc := hpack.NewEncoder(&sliceWriter{buf: &hdrBuf}) + enc.WriteField(hpack.HeaderField{Name: ":method", Value: "POST"}) + enc.WriteField(hpack.HeaderField{Name: ":scheme", Value: "https"}) + enc.WriteField(hpack.HeaderField{Name: ":authority", Value: host}) + if p, ok := headers[":path"]; ok { + enc.WriteField(hpack.HeaderField{Name: ":path", Value: p}) + } + for k, v := range headers { + if len(k) > 0 && k[0] == ':' { + continue + } + enc.WriteField(hpack.HeaderField{Name: k, Value: v}) + } + + if err := framer.WriteHeaders(http2.HeadersFrameParam{ + StreamID: streamID, + BlockFragment: hdrBuf, + EndStream: false, + EndHeaders: true, + }); err != nil { + tlsConn.Close() + return nil, fmt.Errorf("h2: headers write failed: %w", err) + } + + s := &H2Stream{ + framer: framer, + conn: tlsConn, + streamID: streamID, + dataCh: make(chan []byte, 256), + doneCh: make(chan struct{}), + id: fmt.Sprintf("%d-%s", streamID, time.Now().Format("150405.000")), + frameNum: 0, + } + go s.readLoop() + return s, nil +} + +// Write sends a DATA frame on the stream. +func (s *H2Stream) Write(data []byte) error { + s.mu.Lock() + defer s.mu.Unlock() + const maxFrame = 16384 + for len(data) > 0 { + chunk := data + if len(chunk) > maxFrame { + chunk = data[:maxFrame] + } + data = data[len(chunk):] + if err := s.framer.WriteData(s.streamID, false, chunk); err != nil { + return err + } + } + // Try to flush the underlying connection if it supports it + if flusher, ok := s.conn.(interface{ Flush() error }); ok { + flusher.Flush() + } + return nil +} + +// Data returns the channel of received data chunks. +func (s *H2Stream) Data() <-chan []byte { return s.dataCh } + +// Done returns a channel closed when the stream ends. +func (s *H2Stream) Done() <-chan struct{} { return s.doneCh } + +// Close tears down the connection. +func (s *H2Stream) Close() { + s.conn.Close() +} + +func (s *H2Stream) readLoop() { + defer close(s.doneCh) + defer close(s.dataCh) + log.Debugf("h2stream[%s]: readLoop started for streamID=%d", s.id, s.streamID) + + for { + f, err := s.framer.ReadFrame() + if err != nil { + if err != io.EOF { + s.err = err + log.Debugf("h2stream[%s]: readLoop error: %v", s.id, err) + } else { + log.Debugf("h2stream[%s]: readLoop EOF", s.id) + } + return + } + + // Increment frame counter for debugging + s.mu.Lock() + s.frameNum++ + frameNum := s.frameNum + s.mu.Unlock() + + switch frame := f.(type) { + case *http2.DataFrame: + log.Debugf("h2stream[%s]: frame#%d received DATA frame streamID=%d, len=%d, endStream=%v", s.id, frameNum, frame.StreamID, len(frame.Data()), frame.StreamEnded()) + if frame.StreamID == s.streamID && len(frame.Data()) > 0 { + cp := make([]byte, len(frame.Data())) + copy(cp, frame.Data()) + // Log first 20 bytes for debugging + previewLen := len(cp) + if previewLen > 20 { + previewLen = 20 + } + log.Debugf("h2stream[%s]: frame#%d sending to dataCh: len=%d, dataCh len=%d/%d, first bytes: %x (%q)", s.id, frameNum, len(cp), len(s.dataCh), cap(s.dataCh), cp[:previewLen], string(cp[:previewLen])) + s.dataCh <- cp + + // Flow control: send WINDOW_UPDATE + s.mu.Lock() + s.framer.WriteWindowUpdate(0, uint32(len(cp))) + s.framer.WriteWindowUpdate(s.streamID, uint32(len(cp))) + s.mu.Unlock() + } + if frame.StreamEnded() { + log.Debugf("h2stream[%s]: frame#%d DATA frame has END_STREAM flag, stream ending", s.id, frameNum) + return + } + + case *http2.HeadersFrame: + // Decode HPACK headers for debugging + decoder := hpack.NewDecoder(4096, func(hf hpack.HeaderField) { + log.Debugf("h2stream[%s]: frame#%d header: %s = %q", s.id, frameNum, hf.Name, hf.Value) + // Check for error status + if hf.Name == "grpc-status" || hf.Name == ":status" && hf.Value != "200" { + log.Warnf("h2stream[%s]: frame#%d received error status header: %s = %q", s.id, frameNum, hf.Name, hf.Value) + } + }) + decoder.Write(frame.HeaderBlockFragment()) + log.Debugf("h2stream[%s]: frame#%d received HEADERS frame streamID=%d, endStream=%v", s.id, frameNum, frame.StreamID, frame.StreamEnded()) + if frame.StreamEnded() { + log.Debugf("h2stream[%s]: frame#%d HEADERS frame has END_STREAM flag, stream ending", s.id, frameNum) + return + } + + case *http2.RSTStreamFrame: + s.err = fmt.Errorf("h2: RST_STREAM code=%d", frame.ErrCode) + log.Debugf("h2stream[%s]: frame#%d received RST_STREAM code=%d", s.id, frameNum, frame.ErrCode) + return + + case *http2.GoAwayFrame: + s.err = fmt.Errorf("h2: GOAWAY code=%d", frame.ErrCode) + log.Debugf("h2stream[%s]: received GOAWAY code=%d", s.id, frame.ErrCode) + return + + case *http2.PingFrame: + log.Debugf("h2stream[%s]: received PING frame, isAck=%v", s.id, frame.IsAck()) + if !frame.IsAck() { + s.mu.Lock() + s.framer.WritePing(true, frame.Data) + s.mu.Unlock() + } + + case *http2.SettingsFrame: + log.Debugf("h2stream[%s]: received SETTINGS frame, isAck=%v, numSettings=%d", s.id, frame.IsAck(), frame.NumSettings()) + if !frame.IsAck() { + s.mu.Lock() + s.framer.WriteSettingsAck() + s.mu.Unlock() + } + + case *http2.WindowUpdateFrame: + log.Debugf("h2stream[%s]: received WINDOW_UPDATE frame", s.id) + } + } +} + +type sliceWriter struct{ buf *[]byte } + +func (w *sliceWriter) Write(p []byte) (int, error) { + *w.buf = append(*w.buf, p...) + return len(p), nil +} diff --git a/internal/cmd/auth_manager.go b/internal/cmd/auth_manager.go index 83f42e0c..df62281e 100644 --- a/internal/cmd/auth_manager.go +++ b/internal/cmd/auth_manager.go @@ -24,6 +24,7 @@ func newAuthManager() *sdkAuth.Manager { sdkAuth.NewKiloAuthenticator(), sdkAuth.NewGitLabAuthenticator(), sdkAuth.NewCodeBuddyAuthenticator(), + sdkAuth.NewCursorAuthenticator(), ) return manager } diff --git a/internal/cmd/cursor_login.go b/internal/cmd/cursor_login.go new file mode 100644 index 00000000..0ffdef1b --- /dev/null +++ b/internal/cmd/cursor_login.go @@ -0,0 +1,38 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" + log "github.com/sirupsen/logrus" +) + +// DoCursorLogin triggers the OAuth PKCE flow for Cursor and saves tokens. +func DoCursorLogin(cfg *config.Config, options *LoginOptions) { + if options == nil { + options = &LoginOptions{} + } + + manager := newAuthManager() + authOpts := &sdkAuth.LoginOptions{ + NoBrowser: options.NoBrowser, + Metadata: map[string]string{}, + Prompt: options.Prompt, + } + + record, savedPath, err := manager.Login(context.Background(), "cursor", cfg, authOpts) + if err != nil { + log.Errorf("Cursor authentication failed: %v", err) + return + } + + if savedPath != "" { + fmt.Printf("Authentication saved to %s\n", savedPath) + } + if record != nil && record.Label != "" { + fmt.Printf("Authenticated as %s\n", record.Label) + } + fmt.Println("Cursor authentication successful!") +} diff --git a/internal/registry/model_definitions.go b/internal/registry/model_definitions.go index 6764a962..9299ca22 100644 --- a/internal/registry/model_definitions.go +++ b/internal/registry/model_definitions.go @@ -231,11 +231,25 @@ func GetStaticModelDefinitionsByChannel(channel string) []*ModelInfo { return GetAntigravityModels() case "codebuddy": return GetCodeBuddyModels() + case "cursor": + return GetCursorModels() default: return nil } } +// GetCursorModels returns the fallback Cursor model definitions. +func GetCursorModels() []*ModelInfo { + return []*ModelInfo{ + {ID: "composer-2", Object: "model", OwnedBy: "cursor", Type: "cursor", DisplayName: "Composer 2", ContextLength: 200000, MaxCompletionTokens: 64000, Thinking: &ThinkingSupport{Max: 50000, DynamicAllowed: true}}, + {ID: "claude-4-sonnet", Object: "model", OwnedBy: "cursor", Type: "cursor", DisplayName: "Claude 4 Sonnet", ContextLength: 200000, MaxCompletionTokens: 64000, Thinking: &ThinkingSupport{Max: 50000, DynamicAllowed: true}}, + {ID: "claude-3.5-sonnet", Object: "model", OwnedBy: "cursor", Type: "cursor", DisplayName: "Claude 3.5 Sonnet", ContextLength: 200000, MaxCompletionTokens: 8192}, + {ID: "gpt-4o", Object: "model", OwnedBy: "cursor", Type: "cursor", DisplayName: "GPT-4o", ContextLength: 128000, MaxCompletionTokens: 16384}, + {ID: "cursor-small", Object: "model", OwnedBy: "cursor", Type: "cursor", DisplayName: "Cursor Small", ContextLength: 200000, MaxCompletionTokens: 64000}, + {ID: "gemini-2.5-pro", Object: "model", OwnedBy: "cursor", Type: "cursor", DisplayName: "Gemini 2.5 Pro", ContextLength: 1000000, MaxCompletionTokens: 65536, Thinking: &ThinkingSupport{Max: 50000, DynamicAllowed: true}}, + } +} + // LookupStaticModelInfo searches all static model definitions for a model by ID. // Returns nil if no matching model is found. func LookupStaticModelInfo(modelID string) *ModelInfo { @@ -260,6 +274,7 @@ func LookupStaticModelInfo(modelID string) *ModelInfo { GetKiloModels(), GetAmazonQModels(), GetCodeBuddyModels(), + GetCursorModels(), } for _, models := range allModels { for _, m := range models { diff --git a/internal/runtime/executor/cursor_executor.go b/internal/runtime/executor/cursor_executor.go new file mode 100644 index 00000000..5519e92d --- /dev/null +++ b/internal/runtime/executor/cursor_executor.go @@ -0,0 +1,1341 @@ +package executor + +import ( + "bytes" + "context" + "crypto/sha256" + "crypto/tls" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" + + "github.com/google/uuid" + cursorauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/cursor" + cursorproto "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/cursor/proto" + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" + log "github.com/sirupsen/logrus" + "github.com/tidwall/gjson" + "golang.org/x/net/http2" +) + +const ( + cursorAPIURL = "https://api2.cursor.sh" + cursorRunPath = "/agent.v1.AgentService/Run" + cursorModelsPath = "/agent.v1.AgentService/GetUsableModels" + cursorClientVersion = "cli-2026.02.13-41ac335" + cursorAuthType = "cursor" + cursorHeartbeatInterval = 5 * time.Second + cursorSessionTTL = 5 * time.Minute +) + +// CursorExecutor handles requests to the Cursor API via Connect+Protobuf protocol. +type CursorExecutor struct { + cfg *config.Config + mu sync.Mutex + sessions map[string]*cursorSession +} + +type cursorSession struct { + stream *cursorproto.H2Stream + blobStore map[string][]byte + mcpTools []cursorproto.McpToolDef + pending []pendingMcpExec + cancel context.CancelFunc // cancels the session-scoped heartbeat (NOT tied to HTTP request) + createdAt time.Time +} + +type pendingMcpExec struct { + ExecMsgId uint32 + ExecId string + ToolCallId string + ToolName string + Args string // JSON-encoded args +} + +// NewCursorExecutor constructs a new executor instance. +func NewCursorExecutor(cfg *config.Config) *CursorExecutor { + e := &CursorExecutor{ + cfg: cfg, + sessions: make(map[string]*cursorSession), + } + go e.cleanupLoop() + return e +} + +// Identifier implements ProviderExecutor. +func (e *CursorExecutor) Identifier() string { return cursorAuthType } + +// CloseExecutionSession implements ExecutionSessionCloser. +func (e *CursorExecutor) CloseExecutionSession(sessionID string) { + e.mu.Lock() + defer e.mu.Unlock() + if sessionID == cliproxyauth.CloseAllExecutionSessionsID { + for k, s := range e.sessions { + s.cancel() + delete(e.sessions, k) + } + return + } + if s, ok := e.sessions[sessionID]; ok { + s.cancel() + delete(e.sessions, sessionID) + } +} + +func (e *CursorExecutor) cleanupLoop() { + ticker := time.NewTicker(1 * time.Minute) + defer ticker.Stop() + for range ticker.C { + e.mu.Lock() + for k, s := range e.sessions { + if time.Since(s.createdAt) > cursorSessionTTL { + s.cancel() + delete(e.sessions, k) + } + } + e.mu.Unlock() + } +} + +// PrepareRequest implements ProviderExecutor (for HttpRequest support). +func (e *CursorExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error { + token := cursorAccessToken(auth) + if token == "" { + return fmt.Errorf("cursor: access token not found") + } + req.Header.Set("Authorization", "Bearer "+token) + return nil +} + +// HttpRequest injects credentials and executes the request. +func (e *CursorExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) { + if req == nil { + return nil, fmt.Errorf("cursor: request is nil") + } + if err := e.PrepareRequest(req, auth); err != nil { + return nil, err + } + return http.DefaultClient.Do(req) +} + +// CountTokens estimates token count locally using tiktoken. +func (e *CursorExecutor) CountTokens(_ context.Context, _ *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { + defer func() { + if err != nil { + log.Warnf("cursor CountTokens error: %v", err) + } else { + log.Debugf("cursor CountTokens: model=%s result=%s", req.Model, string(resp.Payload)) + } + }() + model := gjson.GetBytes(req.Payload, "model").String() + if model == "" { + model = req.Model + } + + enc, err := getTokenizer(model) + if err != nil { + // Fallback: return zero tokens rather than error (avoids 502) + return cliproxyexecutor.Response{Payload: buildOpenAIUsageJSON(0)}, nil + } + + // Detect format: Claude (/v1/messages) vs OpenAI (/v1/chat/completions) + var count int64 + if gjson.GetBytes(req.Payload, "system").Exists() || opts.SourceFormat.String() == "claude" { + count, _ = countClaudeChatTokens(enc, req.Payload) + } else { + count, _ = countOpenAIChatTokens(enc, req.Payload) + } + + return cliproxyexecutor.Response{Payload: buildOpenAIUsageJSON(count)}, nil +} + +// Refresh attempts to refresh the Cursor access token. +func (e *CursorExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) { + refreshToken := cursorRefreshToken(auth) + if refreshToken == "" { + return nil, fmt.Errorf("cursor: no refresh token available") + } + + tokens, err := cursorauth.RefreshToken(ctx, refreshToken) + if err != nil { + return nil, err + } + + expiresAt := cursorauth.GetTokenExpiry(tokens.AccessToken) + + newAuth := auth.Clone() + newAuth.Metadata["access_token"] = tokens.AccessToken + newAuth.Metadata["refresh_token"] = tokens.RefreshToken + newAuth.Metadata["expires_at"] = expiresAt.Format(time.RFC3339) + return newAuth, nil +} + +// Execute handles non-streaming requests. +func (e *CursorExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { + log.Debugf("cursor Execute: model=%s sourceFormat=%s payloadLen=%d", req.Model, opts.SourceFormat, len(req.Payload)) + defer func() { + if r := recover(); r != nil { + log.Errorf("cursor Execute PANIC: %v", r) + err = fmt.Errorf("cursor: internal panic: %v", r) + } + if err != nil { + log.Warnf("cursor Execute error: %v", err) + } + }() + accessToken := cursorAccessToken(auth) + if accessToken == "" { + return resp, fmt.Errorf("cursor: access token not found") + } + + // Translate input to OpenAI format if needed (e.g. Claude /v1/messages format) + from := opts.SourceFormat + to := sdktranslator.FromString("openai") + payload := req.Payload + if from.String() != "" && from.String() != "openai" { + payload = sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(payload), false) + } + + parsed := parseOpenAIRequest(payload) + params := buildRunRequestParams(parsed) + + requestBytes := cursorproto.EncodeRunRequest(params) + framedRequest := cursorproto.FrameConnectMessage(requestBytes, 0) + + stream, err := openCursorH2Stream(accessToken) + if err != nil { + return resp, err + } + defer stream.Close() + + // Send the request frame + if err := stream.Write(framedRequest); err != nil { + return resp, fmt.Errorf("cursor: failed to send request: %w", err) + } + + // Start heartbeat + sessionCtx, sessionCancel := context.WithCancel(ctx) + defer sessionCancel() + go cursorH2Heartbeat(sessionCtx, stream) + + // Collect full text from streaming response + var fullText strings.Builder + processH2SessionFrames(sessionCtx, stream, params.BlobStore, nil, + func(text string, isThinking bool) { + fullText.WriteString(text) + }, + nil, + ) + + id := "chatcmpl-" + uuid.New().String()[:28] + created := time.Now().Unix() + openaiResp := fmt.Sprintf(`{"id":"%s","object":"chat.completion","created":%d,"model":"%s","choices":[{"index":0,"message":{"role":"assistant","content":%s},"finish_reason":"stop"}],"usage":{"prompt_tokens":0,"completion_tokens":0,"total_tokens":0}}`, + id, created, parsed.Model, jsonString(fullText.String())) + + // Translate response back to source format if needed + result := []byte(openaiResp) + if from.String() != "" && from.String() != "openai" { + var param any + result = sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), payload, result, ¶m) + } + resp.Payload = result + return resp, nil +} + +// ExecuteStream handles streaming requests. +// It supports MCP tool call sessions: when Cursor returns an MCP tool call, +// the H2 stream is kept alive. When Claude Code returns the tool result in +// the next request, the result is sent back on the same stream (session resume). +// This mirrors the activeSessions/resumeWithToolResults pattern in cursor-fetch.ts. +func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) { + log.Debugf("cursor ExecuteStream: model=%s sourceFormat=%s payloadLen=%d", req.Model, opts.SourceFormat, len(req.Payload)) + defer func() { + if r := recover(); r != nil { + log.Errorf("cursor ExecuteStream PANIC: %v", r) + err = fmt.Errorf("cursor: internal panic: %v", r) + } + if err != nil { + log.Warnf("cursor ExecuteStream error: %v", err) + } + }() + accessToken := cursorAccessToken(auth) + if accessToken == "" { + return nil, fmt.Errorf("cursor: access token not found") + } + + // Translate input to OpenAI format if needed + from := opts.SourceFormat + to := sdktranslator.FromString("openai") + payload := req.Payload + originalPayload := bytes.Clone(req.Payload) + if len(opts.OriginalRequest) > 0 { + originalPayload = bytes.Clone(opts.OriginalRequest) + } + if from.String() != "" && from.String() != "openai" { + log.Debugf("cursor: translating request from %s to openai", from) + payload = sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(payload), true) + log.Debugf("cursor: translated payload len=%d", len(payload)) + } + + parsed := parseOpenAIRequest(payload) + log.Debugf("cursor: parsed request: model=%s userText=%d chars, turns=%d, tools=%d, toolResults=%d", + parsed.Model, len(parsed.UserText), len(parsed.Turns), len(parsed.Tools), len(parsed.ToolResults)) + + sessionKey := deriveSessionKey(parsed.Model, parsed.Messages) + needsTranslate := from.String() != "" && from.String() != "openai" + + // Check if we can resume an existing session with tool results + if len(parsed.ToolResults) > 0 { + e.mu.Lock() + session, hasSession := e.sessions[sessionKey] + if hasSession { + delete(e.sessions, sessionKey) + } + e.mu.Unlock() + + if hasSession && session.stream != nil { + log.Debugf("cursor: resuming session %s with %d tool results", sessionKey, len(parsed.ToolResults)) + return e.resumeWithToolResults(ctx, session, parsed, from, to, req, originalPayload, payload, needsTranslate) + } + } + + // Clean up any stale session for this key + e.mu.Lock() + if old, ok := e.sessions[sessionKey]; ok { + old.cancel() + delete(e.sessions, sessionKey) + } + e.mu.Unlock() + + params := buildRunRequestParams(parsed) + requestBytes := cursorproto.EncodeRunRequest(params) + framedRequest := cursorproto.FrameConnectMessage(requestBytes, 0) + + stream, err := openCursorH2Stream(accessToken) + if err != nil { + return nil, err + } + + if err := stream.Write(framedRequest); err != nil { + stream.Close() + return nil, fmt.Errorf("cursor: failed to send request: %w", err) + } + + // Use a session-scoped context for the heartbeat that is NOT tied to the HTTP request. + // This ensures the heartbeat survives across request boundaries during MCP tool execution. + // Mirrors the TS plugin's setInterval-based heartbeat that lives independently of HTTP responses. + sessionCtx, sessionCancel := context.WithCancel(context.Background()) + go cursorH2Heartbeat(sessionCtx, stream) + + chunks := make(chan cliproxyexecutor.StreamChunk, 64) + chatId := "chatcmpl-" + uuid.New().String()[:28] + created := time.Now().Unix() + + // sendChunk builds an OpenAI SSE line and optionally translates to target format + var streamParam any + sendChunk := func(delta string, finishReason string) { + fr := "null" + if finishReason != "" { + fr = finishReason + } + openaiJSON := fmt.Sprintf(`{"id":"%s","object":"chat.completion.chunk","created":%d,"model":"%s","choices":[{"index":0,"delta":%s,"finish_reason":%s}]}`, + chatId, created, parsed.Model, delta, fr) + sseLine := []byte("data: " + openaiJSON + "\n") + + if needsTranslate { + translated := sdktranslator.TranslateStream(ctx, to, from, req.Model, originalPayload, payload, sseLine, &streamParam) + for _, t := range translated { + chunks <- cliproxyexecutor.StreamChunk{Payload: bytes.Clone(t)} + } + } else { + chunks <- cliproxyexecutor.StreamChunk{Payload: []byte(openaiJSON)} + } + } + + sendDone := func() { + if needsTranslate { + done := sdktranslator.TranslateStream(ctx, to, from, req.Model, originalPayload, payload, []byte("data: [DONE]\n"), &streamParam) + for _, d := range done { + chunks <- cliproxyexecutor.StreamChunk{Payload: bytes.Clone(d)} + } + } else { + chunks <- cliproxyexecutor.StreamChunk{Payload: []byte("[DONE]")} + } + } + + go func() { + defer close(chunks) + + thinkingActive := false + toolCallIndex := 0 + mcpExecReceived := false + + processH2SessionFrames(sessionCtx, stream, params.BlobStore, params.McpTools, + func(text string, isThinking bool) { + if isThinking { + if !thinkingActive { + thinkingActive = true + sendChunk(`{"role":"assistant","content":""}`, "") + } + sendChunk(fmt.Sprintf(`{"content":%s}`, jsonString(text)), "") + } else { + if thinkingActive { + thinkingActive = false + sendChunk(`{"content":""}`, "") + } + sendChunk(fmt.Sprintf(`{"content":%s}`, jsonString(text)), "") + } + }, + func(exec pendingMcpExec) { + mcpExecReceived = true + if thinkingActive { + thinkingActive = false + sendChunk(`{"content":""}`, "") + } + toolCallJSON := fmt.Sprintf(`{"tool_calls":[{"index":%d,"id":"%s","type":"function","function":{"name":"%s","arguments":%s}}]}`, + toolCallIndex, exec.ToolCallId, exec.ToolName, jsonString(exec.Args)) + toolCallIndex++ + sendChunk(toolCallJSON, "") + sendChunk(`{}`, `"tool_calls"`) + sendDone() + + // Save session for resume — keep stream alive. + // The heartbeat goroutine continues running (session-scoped context), + // keeping the H2 connection alive while the MCP tool executes. + log.Debugf("cursor: saving session %s for MCP tool resume (tool=%s, streamID=%s)", sessionKey, exec.ToolName, stream.ID()) + e.mu.Lock() + e.sessions[sessionKey] = &cursorSession{ + stream: stream, + blobStore: params.BlobStore, + mcpTools: params.McpTools, + pending: []pendingMcpExec{exec}, + cancel: sessionCancel, + createdAt: time.Now(), + } + e.mu.Unlock() + }, + ) + + if !mcpExecReceived { + if thinkingActive { + sendChunk(`{"content":""}`, "") + } + sendChunk(`{}`, `"stop"`) + sendDone() + sessionCancel() + stream.Close() + } + // If mcpExecReceived, do NOT close stream or cancel — session resume will handle it + }() + + return &cliproxyexecutor.StreamResult{Chunks: chunks}, nil +} + +// resumeWithToolResults sends MCP tool results back on the existing H2 stream, +// then continues reading the stream for the model's response. +// Mirrors resumeWithToolResults() in cursor-fetch.ts. +func (e *CursorExecutor) resumeWithToolResults( + ctx context.Context, + session *cursorSession, + parsed *parsedOpenAIRequest, + from, to sdktranslator.Format, + req cliproxyexecutor.Request, + originalPayload, payload []byte, + needsTranslate bool, +) (*cliproxyexecutor.StreamResult, error) { + stream := session.stream + log.Debugf("cursor: resumeWithToolResults: using stream ID=%s", stream.ID()) + + // Cancel old session-scoped heartbeat before starting a new one + session.cancel() + + // CRITICAL: Process any pending messages from the channel before sending MCP result. + // After the initial processH2SessionFrames returned (upon receiving MCP args), + // the server may have sent more data (KV messages, text deltas) that are now buffered in dataCh. + // We must process KV messages (respond to them) but discard text deltas (stale responses). + drainedCount := 0 + drainedBytes := 0 + kvProcessedCount := 0 + for { + select { + case staleData, ok := <-stream.Data(): + if !ok { + log.Debugf("cursor: resumeWithToolResults: dataCh closed during drain") + break + } + drainedCount++ + drainedBytes += len(staleData) + log.Debugf("cursor: resumeWithToolResults: processing stale data #%d: len=%d, first bytes: %x (%q)", drainedCount, len(staleData), staleData[:min(20, len(staleData))], string(staleData[:min(20, len(staleData))])) + + // Try to decode and handle KV messages (they need responses) + if len(staleData) > 5 { + frameLen := binary.BigEndian.Uint32(staleData[1:5]) + if int(frameLen)+5 <= len(staleData) { + payload := staleData[5 : 5+frameLen] + msg, err := cursorproto.DecodeAgentServerMessage(payload) + if err == nil && msg.Type == cursorproto.ServerMsgKvGetBlob { + // Respond to KV getBlob + blobKey := cursorproto.BlobIdHex(msg.BlobId) + data := session.blobStore[blobKey] + log.Debugf("cursor: resumeWithToolResults: responding to stale KV getBlob kvId=%d blobKey=%s found=%v", msg.KvId, blobKey, len(data) > 0) + resp := cursorproto.EncodeKvGetBlobResult(msg.KvId, data) + stream.Write(cursorproto.FrameConnectMessage(resp, 0)) + kvProcessedCount++ + continue + } else if err == nil && msg.Type == cursorproto.ServerMsgKvSetBlob { + // Respond to KV setBlob + blobKey := cursorproto.BlobIdHex(msg.BlobId) + session.blobStore[blobKey] = append([]byte(nil), msg.BlobData...) + log.Debugf("cursor: resumeWithToolResults: responding to stale KV setBlob kvId=%d blobKey=%s", msg.KvId, blobKey) + resp := cursorproto.EncodeKvSetBlobResult(msg.KvId) + stream.Write(cursorproto.FrameConnectMessage(resp, 0)) + kvProcessedCount++ + continue + } + } + } + log.Debugf("cursor: resumeWithToolResults: discarding non-KV stale data") + default: + // No more data in channel + goto drainDone + } + } +drainDone: + if drainedCount > 0 { + log.Debugf("cursor: resumeWithToolResults: processed %d stale frames (%d bytes total, %d KV responded)", drainedCount, drainedBytes, kvProcessedCount) + } + + // Send MCP results back on the same H2 stream + for _, exec := range session.pending { + var content string + var isError bool + found := false + for _, tr := range parsed.ToolResults { + if tr.ToolCallId == exec.ToolCallId { + content = tr.Content + found = true + break + } + } + if !found { + content = "Tool result not provided" + isError = true + } + log.Debugf("cursor: sending MCP result for tool=%s callId=%s execMsgId=%d execId=%s contentLen=%d isError=%v", + exec.ToolName, exec.ToolCallId, exec.ExecMsgId, exec.ExecId, len(content), isError) + resultBytes := cursorproto.EncodeExecMcpResult(exec.ExecMsgId, exec.ExecId, content, isError) + framedResult := cursorproto.FrameConnectMessage(resultBytes, 0) + // Log the framed result details for debugging + log.Debugf("cursor: MCP result frame size=%d bytes", len(framedResult)) + log.Debugf("cursor: MCP result frame header: flags=%d, len=%d", framedResult[0], binary.BigEndian.Uint32(framedResult[1:5])) + log.Debugf("cursor: MCP result protobuf hex (first 50 bytes): %x", resultBytes[:min(50, len(resultBytes))]) + if err := stream.Write(framedResult); err != nil { + stream.Close() + return nil, fmt.Errorf("cursor: failed to send MCP result: %w", err) + } + log.Debugf("cursor: MCP result sent successfully for tool=%s", exec.ToolName) + } + + // Start new session-scoped heartbeat (independent of HTTP request context) + sessionCtx, sessionCancel := context.WithCancel(context.Background()) + go cursorH2Heartbeat(sessionCtx, stream) + log.Debugf("cursor: started new heartbeat for resumed session, waiting for Cursor response...") + + chunks := make(chan cliproxyexecutor.StreamChunk, 64) + chatId := "chatcmpl-" + uuid.New().String()[:28] + created := time.Now().Unix() + sessionKey := deriveSessionKey(parsed.Model, parsed.Messages) + + var streamParam any + sendChunk := func(delta string, finishReason string) { + fr := "null" + if finishReason != "" { + fr = finishReason + } + openaiJSON := fmt.Sprintf(`{"id":"%s","object":"chat.completion.chunk","created":%d,"model":"%s","choices":[{"index":0,"delta":%s,"finish_reason":%s}]}`, + chatId, created, parsed.Model, delta, fr) + sseLine := []byte("data: " + openaiJSON + "\n") + + if needsTranslate { + translated := sdktranslator.TranslateStream(ctx, to, from, req.Model, originalPayload, payload, sseLine, &streamParam) + for _, t := range translated { + chunks <- cliproxyexecutor.StreamChunk{Payload: bytes.Clone(t)} + } + } else { + chunks <- cliproxyexecutor.StreamChunk{Payload: []byte(openaiJSON)} + } + } + + sendDone := func() { + if needsTranslate { + done := sdktranslator.TranslateStream(ctx, to, from, req.Model, originalPayload, payload, []byte("data: [DONE]\n"), &streamParam) + for _, d := range done { + chunks <- cliproxyexecutor.StreamChunk{Payload: bytes.Clone(d)} + } + } else { + chunks <- cliproxyexecutor.StreamChunk{Payload: []byte("[DONE]")} + } + } + + go func() { + defer func() { + log.Debugf("cursor: resume goroutine exiting, closing chunks channel") + close(chunks) + }() + log.Debugf("cursor: resume goroutine started, entering processH2SessionFrames") + + thinkingActive := false + toolCallIndex := 0 + mcpExecReceived := false + + processH2SessionFrames(sessionCtx, stream, session.blobStore, session.mcpTools, + func(text string, isThinking bool) { + log.Debugf("cursor: resume received text (isThinking=%v, len=%d)", isThinking, len(text)) + if isThinking { + if !thinkingActive { + thinkingActive = true + sendChunk(`{"role":"assistant","content":""}`, "") + } + sendChunk(fmt.Sprintf(`{"content":%s}`, jsonString(text)), "") + } else { + if thinkingActive { + thinkingActive = false + sendChunk(`{"content":""}`, "") + } + sendChunk(fmt.Sprintf(`{"content":%s}`, jsonString(text)), "") + } + }, + func(exec pendingMcpExec) { + mcpExecReceived = true + if thinkingActive { + thinkingActive = false + sendChunk(`{"content":""}`, "") + } + toolCallJSON := fmt.Sprintf(`{"tool_calls":[{"index":%d,"id":"%s","type":"function","function":{"name":"%s","arguments":%s}}]}`, + toolCallIndex, exec.ToolCallId, exec.ToolName, jsonString(exec.Args)) + toolCallIndex++ + sendChunk(toolCallJSON, "") + sendChunk(`{}`, `"tool_calls"`) + sendDone() + + // Save session again for another round of tool calls + log.Debugf("cursor: saving session %s for another MCP tool resume (tool=%s, streamID=%s)", sessionKey, exec.ToolName, stream.ID()) + e.mu.Lock() + e.sessions[sessionKey] = &cursorSession{ + stream: stream, + blobStore: session.blobStore, + mcpTools: session.mcpTools, + pending: []pendingMcpExec{exec}, + cancel: sessionCancel, + createdAt: time.Now(), + } + e.mu.Unlock() + }, + ) + + if !mcpExecReceived { + if thinkingActive { + sendChunk(`{"content":""}`, "") + } + sendChunk(`{}`, `"stop"`) + sendDone() + sessionCancel() + stream.Close() + } + }() + + return &cliproxyexecutor.StreamResult{Chunks: chunks}, nil +} + +// --- H2Stream helpers --- + +func openCursorH2Stream(accessToken string) (*cursorproto.H2Stream, error) { + headers := map[string]string{ + ":path": cursorRunPath, + "content-type": "application/connect+proto", + "connect-protocol-version": "1", + "te": "trailers", + "authorization": "Bearer " + accessToken, + "x-ghost-mode": "true", + "x-cursor-client-version": cursorClientVersion, + "x-cursor-client-type": "cli", + "x-request-id": uuid.New().String(), + } + return cursorproto.DialH2Stream("api2.cursor.sh", headers) +} + +func cursorH2Heartbeat(ctx context.Context, stream *cursorproto.H2Stream) { + ticker := time.NewTicker(cursorHeartbeatInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + hb := cursorproto.EncodeHeartbeat() + frame := cursorproto.FrameConnectMessage(hb, 0) + if err := stream.Write(frame); err != nil { + return + } + } + } +} + +// --- Response processing --- + +func processH2SessionFrames( + ctx context.Context, + stream *cursorproto.H2Stream, + blobStore map[string][]byte, + mcpTools []cursorproto.McpToolDef, + onText func(text string, isThinking bool), + onMcpExec func(exec pendingMcpExec), +) { + var buf bytes.Buffer + rejectReason := "Tool not available in this environment. Use the MCP tools provided instead." + log.Debugf("cursor: processH2SessionFrames started for streamID=%s, waiting for data...", stream.ID()) + for { + select { + case <-ctx.Done(): + log.Debugf("cursor: processH2SessionFrames exiting: context done") + return + case data, ok := <-stream.Data(): + if !ok { + log.Debugf("cursor: processH2SessionFrames[%s]: exiting: stream data channel closed", stream.ID()) + return + } + // Log first 20 bytes of raw data for debugging + previewLen := min(20, len(data)) + log.Debugf("cursor: processH2SessionFrames[%s]: received %d bytes from dataCh, first bytes: %x (%q)", stream.ID(), len(data), data[:previewLen], string(data[:previewLen])) + buf.Write(data) + log.Debugf("cursor: processH2SessionFrames[%s]: buf total=%d", stream.ID(), buf.Len()) + + // Process all complete frames + for { + currentBuf := buf.Bytes() + if len(currentBuf) == 0 { + break + } + flags, payload, consumed, ok := cursorproto.ParseConnectFrame(currentBuf) + if !ok { + // Log detailed info about why parsing failed + previewLen := min(20, len(currentBuf)) + log.Debugf("cursor: incomplete frame in buffer, waiting for more data (buf=%d bytes, first bytes: %x = %q)", len(currentBuf), currentBuf[:previewLen], string(currentBuf[:previewLen])) + break + } + buf.Next(consumed) + log.Debugf("cursor: parsed Connect frame flags=0x%02x payload=%d bytes consumed=%d", flags, len(payload), consumed) + + if flags&cursorproto.ConnectEndStreamFlag != 0 { + if err := cursorproto.ParseConnectEndStream(payload); err != nil { + log.Warnf("cursor: connect end stream error: %v", err) + } + continue + } + + msg, err := cursorproto.DecodeAgentServerMessage(payload) + if err != nil { + log.Debugf("cursor: failed to decode server message: %v", err) + continue + } + + log.Debugf("cursor: decoded server message type=%d", msg.Type) + switch msg.Type { + case cursorproto.ServerMsgTextDelta: + if msg.Text != "" && onText != nil { + onText(msg.Text, false) + } + case cursorproto.ServerMsgThinkingDelta: + if msg.Text != "" && onText != nil { + onText(msg.Text, true) + } + case cursorproto.ServerMsgThinkingCompleted: + // Handled by caller + + case cursorproto.ServerMsgKvGetBlob: + blobKey := cursorproto.BlobIdHex(msg.BlobId) + data := blobStore[blobKey] + resp := cursorproto.EncodeKvGetBlobResult(msg.KvId, data) + stream.Write(cursorproto.FrameConnectMessage(resp, 0)) + + case cursorproto.ServerMsgKvSetBlob: + blobKey := cursorproto.BlobIdHex(msg.BlobId) + blobStore[blobKey] = append([]byte(nil), msg.BlobData...) + resp := cursorproto.EncodeKvSetBlobResult(msg.KvId) + stream.Write(cursorproto.FrameConnectMessage(resp, 0)) + + case cursorproto.ServerMsgExecRequestCtx: + resp := cursorproto.EncodeExecRequestContextResult(msg.ExecMsgId, msg.ExecId, mcpTools) + stream.Write(cursorproto.FrameConnectMessage(resp, 0)) + + case cursorproto.ServerMsgExecMcpArgs: + if onMcpExec != nil { + decodedArgs := decodeMcpArgsToJSON(msg.McpArgs) + toolCallId := msg.McpToolCallId + if toolCallId == "" { + toolCallId = uuid.New().String() + } + // Debug: log the received execId from server + log.Debugf("cursor: received mcpArgs from server: execMsgId=%d execId=%q toolName=%s toolCallId=%s", + msg.ExecMsgId, msg.ExecId, msg.McpToolName, toolCallId) + onMcpExec(pendingMcpExec{ + ExecMsgId: msg.ExecMsgId, + ExecId: msg.ExecId, + ToolCallId: toolCallId, + ToolName: msg.McpToolName, + Args: decodedArgs, + }) + return + } + + case cursorproto.ServerMsgExecReadArgs: + stream.Write(cursorproto.FrameConnectMessage(cursorproto.EncodeExecReadRejected(msg.ExecMsgId, msg.ExecId, msg.Path, rejectReason), 0)) + case cursorproto.ServerMsgExecWriteArgs: + stream.Write(cursorproto.FrameConnectMessage(cursorproto.EncodeExecWriteRejected(msg.ExecMsgId, msg.ExecId, msg.Path, rejectReason), 0)) + case cursorproto.ServerMsgExecDeleteArgs: + stream.Write(cursorproto.FrameConnectMessage(cursorproto.EncodeExecDeleteRejected(msg.ExecMsgId, msg.ExecId, msg.Path, rejectReason), 0)) + case cursorproto.ServerMsgExecLsArgs: + stream.Write(cursorproto.FrameConnectMessage(cursorproto.EncodeExecLsRejected(msg.ExecMsgId, msg.ExecId, msg.Path, rejectReason), 0)) + case cursorproto.ServerMsgExecGrepArgs: + stream.Write(cursorproto.FrameConnectMessage(cursorproto.EncodeExecGrepError(msg.ExecMsgId, msg.ExecId, rejectReason), 0)) + case cursorproto.ServerMsgExecShellArgs, cursorproto.ServerMsgExecShellStream: + stream.Write(cursorproto.FrameConnectMessage(cursorproto.EncodeExecShellRejected(msg.ExecMsgId, msg.ExecId, msg.Command, msg.WorkingDirectory, rejectReason), 0)) + case cursorproto.ServerMsgExecBgShellSpawn: + stream.Write(cursorproto.FrameConnectMessage(cursorproto.EncodeExecBackgroundShellSpawnRejected(msg.ExecMsgId, msg.ExecId, msg.Command, msg.WorkingDirectory, rejectReason), 0)) + case cursorproto.ServerMsgExecFetchArgs: + stream.Write(cursorproto.FrameConnectMessage(cursorproto.EncodeExecFetchError(msg.ExecMsgId, msg.ExecId, msg.Url, rejectReason), 0)) + case cursorproto.ServerMsgExecDiagnostics: + stream.Write(cursorproto.FrameConnectMessage(cursorproto.EncodeExecDiagnosticsResult(msg.ExecMsgId, msg.ExecId), 0)) + case cursorproto.ServerMsgExecWriteShellStdin: + stream.Write(cursorproto.FrameConnectMessage(cursorproto.EncodeExecWriteShellStdinError(msg.ExecMsgId, msg.ExecId, rejectReason), 0)) + } + } + + case <-stream.Done(): + log.Debugf("cursor: processH2SessionFrames exiting: stream done") + return + } + } +} + +// --- OpenAI request parsing --- + +type parsedOpenAIRequest struct { + Model string + Messages []gjson.Result + Tools []gjson.Result + Stream bool + SystemPrompt string + UserText string + Images []cursorproto.ImageData + Turns []cursorproto.TurnData + ToolResults []toolResultInfo +} + +type toolResultInfo struct { + ToolCallId string + Content string +} + +func parseOpenAIRequest(payload []byte) *parsedOpenAIRequest { + p := &parsedOpenAIRequest{ + Model: gjson.GetBytes(payload, "model").String(), + Stream: gjson.GetBytes(payload, "stream").Bool(), + } + + messages := gjson.GetBytes(payload, "messages").Array() + p.Messages = messages + + // Extract system prompt + var systemParts []string + for _, msg := range messages { + if msg.Get("role").String() == "system" { + systemParts = append(systemParts, extractTextContent(msg.Get("content"))) + } + } + if len(systemParts) > 0 { + p.SystemPrompt = strings.Join(systemParts, "\n") + } else { + p.SystemPrompt = "You are a helpful assistant." + } + + // Extract turns, tool results, and last user message + var pendingUser string + for _, msg := range messages { + role := msg.Get("role").String() + switch role { + case "system": + continue + case "tool": + p.ToolResults = append(p.ToolResults, toolResultInfo{ + ToolCallId: msg.Get("tool_call_id").String(), + Content: extractTextContent(msg.Get("content")), + }) + case "user": + if pendingUser != "" { + p.Turns = append(p.Turns, cursorproto.TurnData{UserText: pendingUser}) + } + pendingUser = extractTextContent(msg.Get("content")) + p.Images = extractImages(msg.Get("content")) + case "assistant": + if pendingUser != "" { + p.Turns = append(p.Turns, cursorproto.TurnData{ + UserText: pendingUser, + AssistantText: extractTextContent(msg.Get("content")), + }) + pendingUser = "" + } + } + } + + if pendingUser != "" { + p.UserText = pendingUser + } else if len(p.Turns) > 0 && len(p.ToolResults) == 0 { + last := p.Turns[len(p.Turns)-1] + p.Turns = p.Turns[:len(p.Turns)-1] + p.UserText = last.UserText + } + + // Extract tools + p.Tools = gjson.GetBytes(payload, "tools").Array() + + return p +} + +func extractTextContent(content gjson.Result) string { + if content.Type == gjson.String { + return content.String() + } + if content.IsArray() { + var parts []string + for _, part := range content.Array() { + if part.Get("type").String() == "text" { + parts = append(parts, part.Get("text").String()) + } + } + return strings.Join(parts, "") + } + return content.String() +} + +func extractImages(content gjson.Result) []cursorproto.ImageData { + if !content.IsArray() { + return nil + } + var images []cursorproto.ImageData + for _, part := range content.Array() { + if part.Get("type").String() == "image_url" { + url := part.Get("image_url.url").String() + if strings.HasPrefix(url, "data:") { + img := parseDataURL(url) + if img != nil { + images = append(images, *img) + } + } + } + } + return images +} + +func parseDataURL(url string) *cursorproto.ImageData { + // data:image/png;base64,... + if !strings.HasPrefix(url, "data:") { + return nil + } + parts := strings.SplitN(url[5:], ";", 2) + if len(parts) != 2 { + return nil + } + mimeType := parts[0] + if !strings.HasPrefix(parts[1], "base64,") { + return nil + } + encoded := parts[1][7:] + data, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + // Try RawStdEncoding for unpadded base64 + data, err = base64.RawStdEncoding.DecodeString(encoded) + if err != nil { + return nil + } + } + return &cursorproto.ImageData{ + MimeType: mimeType, + Data: data, + } +} + +func buildRunRequestParams(parsed *parsedOpenAIRequest) *cursorproto.RunRequestParams { + params := &cursorproto.RunRequestParams{ + ModelId: parsed.Model, + SystemPrompt: parsed.SystemPrompt, + UserText: parsed.UserText, + MessageId: uuid.New().String(), + ConversationId: uuid.New().String(), + Images: parsed.Images, + Turns: parsed.Turns, + BlobStore: make(map[string][]byte), + } + + // Convert OpenAI tools to McpToolDefs + for _, tool := range parsed.Tools { + fn := tool.Get("function") + params.McpTools = append(params.McpTools, cursorproto.McpToolDef{ + Name: fn.Get("name").String(), + Description: fn.Get("description").String(), + InputSchema: json.RawMessage(fn.Get("parameters").Raw), + }) + } + + return params +} + +// --- Helpers --- + +func cursorAccessToken(auth *cliproxyauth.Auth) string { + if auth == nil || auth.Metadata == nil { + return "" + } + if v, ok := auth.Metadata["access_token"].(string); ok { + return v + } + return "" +} + +func cursorRefreshToken(auth *cliproxyauth.Auth) string { + if auth == nil || auth.Metadata == nil { + return "" + } + if v, ok := auth.Metadata["refresh_token"].(string); ok { + return v + } + return "" +} + +func applyCursorHeaders(req *http.Request, accessToken string) { + req.Header.Set("Content-Type", "application/connect+proto") + req.Header.Set("Connect-Protocol-Version", "1") + req.Header.Set("Te", "trailers") + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("X-Ghost-Mode", "true") + req.Header.Set("X-Cursor-Client-Version", cursorClientVersion) + req.Header.Set("X-Cursor-Client-Type", "cli") + req.Header.Set("X-Request-Id", uuid.New().String()) +} + +func newH2Client() *http.Client { + return &http.Client{ + Transport: &http2.Transport{ + TLSClientConfig: &tls.Config{}, + }, + } +} + +func deriveSessionKey(model string, messages []gjson.Result) string { + var firstUserContent string + for _, msg := range messages { + if msg.Get("role").String() == "user" { + firstUserContent = extractTextContent(msg.Get("content")) + break + } + } + input := model + ":" + firstUserContent + if len(input) > 200 { + input = input[:200] + } + h := sha256.Sum256([]byte(input)) + return hex.EncodeToString(h[:])[:16] +} + +func sseChunk(id string, created int64, model string, delta string, finishReason string) cliproxyexecutor.StreamChunk { + fr := "null" + if finishReason != "" { + fr = finishReason + } + // Note: the framework's WriteChunk adds "data: " prefix and "\n\n" suffix, + // so we only output the raw JSON here. + data := fmt.Sprintf(`{"id":"%s","object":"chat.completion.chunk","created":%d,"model":"%s","choices":[{"index":0,"delta":%s,"finish_reason":%s}]}`, + id, created, model, delta, fr) + return cliproxyexecutor.StreamChunk{ + Payload: []byte(data), + } +} + +func jsonString(s string) string { + b, _ := json.Marshal(s) + return string(b) +} + +func decodeMcpArgsToJSON(args map[string][]byte) string { + if len(args) == 0 { + return "{}" + } + result := make(map[string]interface{}) + for k, v := range args { + // Try protobuf Value decoding first (matches TS: toJson(ValueSchema, fromBinary(ValueSchema, value))) + if decoded, err := cursorproto.ProtobufValueBytesToJSON(v); err == nil { + result[k] = decoded + } else { + // Fallback: try raw JSON + var jsonVal interface{} + if err := json.Unmarshal(v, &jsonVal); err == nil { + result[k] = jsonVal + } else { + result[k] = string(v) + } + } + } + b, _ := json.Marshal(result) + return string(b) +} + +// --- Model Discovery --- + +// FetchCursorModels retrieves available models from Cursor's API. +func FetchCursorModels(ctx context.Context, auth *cliproxyauth.Auth, cfg *config.Config) []*registry.ModelInfo { + accessToken := cursorAccessToken(auth) + if accessToken == "" { + return GetCursorFallbackModels() + } + + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + // GetUsableModels is a unary RPC call (not streaming) + // Send an empty protobuf request + emptyReq := make([]byte, 0) + + h2Req, err := http.NewRequestWithContext(ctx, http.MethodPost, + cursorAPIURL+cursorModelsPath, bytes.NewReader(emptyReq)) + if err != nil { + log.Debugf("cursor: failed to create models request: %v", err) + return GetCursorFallbackModels() + } + + h2Req.Header.Set("Content-Type", "application/proto") + h2Req.Header.Set("Te", "trailers") + h2Req.Header.Set("Authorization", "Bearer "+accessToken) + h2Req.Header.Set("X-Ghost-Mode", "true") + h2Req.Header.Set("X-Cursor-Client-Version", cursorClientVersion) + h2Req.Header.Set("X-Cursor-Client-Type", "cli") + + client := newH2Client() + resp, err := client.Do(h2Req) + if err != nil { + log.Debugf("cursor: models request failed: %v", err) + return GetCursorFallbackModels() + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + log.Debugf("cursor: models request returned status %d", resp.StatusCode) + return GetCursorFallbackModels() + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return GetCursorFallbackModels() + } + + models := parseModelsResponse(body) + if len(models) == 0 { + return GetCursorFallbackModels() + } + return models +} + +func parseModelsResponse(data []byte) []*registry.ModelInfo { + // Try stripping Connect framing first + if len(data) >= cursorproto.ConnectFrameHeaderSize { + _, payload, _, ok := cursorproto.ParseConnectFrame(data) + if ok { + data = payload + } + } + + // The response is a GetUsableModelsResponse protobuf. + // We need to decode it manually - it contains a repeated "models" field. + // Based on the TS code, the response has a `models` field (repeated) containing + // model objects with modelId, displayName, thinkingDetails, etc. + + // For now, we'll try a simple decode approach + var models []*registry.ModelInfo + // Field 1 is likely "models" (repeated submessage) + for len(data) > 0 { + num, typ, n := consumeTag(data) + if n < 0 { + break + } + data = data[n:] + + if typ == 2 { // BytesType (submessage) + val, n := consumeBytes(data) + if n < 0 { + break + } + data = data[n:] + + if num == 1 { // models field + if m := parseModelEntry(val); m != nil { + models = append(models, m) + } + } + } else { + n := consumeFieldValue(num, typ, data) + if n < 0 { + break + } + data = data[n:] + } + } + + return models +} + +func parseModelEntry(data []byte) *registry.ModelInfo { + var modelId, displayName string + var hasThinking bool + + for len(data) > 0 { + num, typ, n := consumeTag(data) + if n < 0 { + break + } + data = data[n:] + + switch typ { + case 2: // BytesType + val, n := consumeBytes(data) + if n < 0 { + return nil + } + data = data[n:] + switch num { + case 1: // modelId + modelId = string(val) + case 2: // thinkingDetails + hasThinking = true + case 3: // displayModelId (use as fallback) + if displayName == "" { + displayName = string(val) + } + case 4: // displayName + displayName = string(val) + case 5: // displayNameShort + if displayName == "" { + displayName = string(val) + } + } + case 0: // VarintType + _, n := consumeVarint(data) + if n < 0 { + return nil + } + data = data[n:] + default: + n := consumeFieldValue(num, typ, data) + if n < 0 { + return nil + } + data = data[n:] + } + } + + if modelId == "" { + return nil + } + if displayName == "" { + displayName = modelId + } + + info := ®istry.ModelInfo{ + ID: modelId, + Object: "model", + Created: time.Now().Unix(), + OwnedBy: "cursor", + Type: cursorAuthType, + DisplayName: displayName, + ContextLength: 200000, + MaxCompletionTokens: 64000, + } + if hasThinking { + info.Thinking = ®istry.ThinkingSupport{ + Max: 50000, + DynamicAllowed: true, + } + } + return info +} + +// GetCursorFallbackModels returns hardcoded fallback models. +func GetCursorFallbackModels() []*registry.ModelInfo { + return []*registry.ModelInfo{ + {ID: "composer-2", Object: "model", OwnedBy: "cursor", Type: cursorAuthType, DisplayName: "Composer 2", ContextLength: 200000, MaxCompletionTokens: 64000, Thinking: ®istry.ThinkingSupport{Max: 50000, DynamicAllowed: true}}, + {ID: "claude-4-sonnet", Object: "model", OwnedBy: "cursor", Type: cursorAuthType, DisplayName: "Claude 4 Sonnet", ContextLength: 200000, MaxCompletionTokens: 64000, Thinking: ®istry.ThinkingSupport{Max: 50000, DynamicAllowed: true}}, + {ID: "claude-3.5-sonnet", Object: "model", OwnedBy: "cursor", Type: cursorAuthType, DisplayName: "Claude 3.5 Sonnet", ContextLength: 200000, MaxCompletionTokens: 8192}, + {ID: "gpt-4o", Object: "model", OwnedBy: "cursor", Type: cursorAuthType, DisplayName: "GPT-4o", ContextLength: 128000, MaxCompletionTokens: 16384}, + {ID: "cursor-small", Object: "model", OwnedBy: "cursor", Type: cursorAuthType, DisplayName: "Cursor Small", ContextLength: 200000, MaxCompletionTokens: 64000}, + {ID: "gemini-2.5-pro", Object: "model", OwnedBy: "cursor", Type: cursorAuthType, DisplayName: "Gemini 2.5 Pro", ContextLength: 1000000, MaxCompletionTokens: 65536, Thinking: ®istry.ThinkingSupport{Max: 50000, DynamicAllowed: true}}, + } +} + +// Low-level protowire helpers (avoid importing protowire in executor) +func consumeTag(b []byte) (num int, typ int, n int) { + v, n := consumeVarint(b) + if n < 0 { + return 0, 0, -1 + } + return int(v >> 3), int(v & 7), n +} + +func consumeVarint(b []byte) (uint64, int) { + var val uint64 + for i := 0; i < len(b) && i < 10; i++ { + val |= uint64(b[i]&0x7f) << (7 * i) + if b[i]&0x80 == 0 { + return val, i + 1 + } + } + return 0, -1 +} + +func consumeBytes(b []byte) ([]byte, int) { + length, n := consumeVarint(b) + if n < 0 || int(length) > len(b)-n { + return nil, -1 + } + return b[n : n+int(length)], n + int(length) +} + +func consumeFieldValue(num, typ int, b []byte) int { + switch typ { + case 0: // Varint + _, n := consumeVarint(b) + return n + case 1: // 64-bit + if len(b) < 8 { + return -1 + } + return 8 + case 2: // Length-delimited + _, n := consumeBytes(b) + return n + case 5: // 32-bit + if len(b) < 4 { + return -1 + } + return 4 + default: + return -1 + } +} diff --git a/internal/util/provider.go b/internal/util/provider.go index 15351354..a9c1de0c 100644 --- a/internal/util/provider.go +++ b/internal/util/provider.go @@ -57,6 +57,12 @@ func GetProviderName(modelName string) []string { return providers } + // Fallback: if cursor provider has registered models, route unknown models to it. + // Cursor acts as a universal proxy supporting multiple model families (Claude, GPT, Gemini, etc.). + if models := registry.GetGlobalRegistry().GetAvailableModelsByProvider("cursor"); len(models) > 0 { + return []string{"cursor"} + } + return providers } diff --git a/sdk/auth/cursor.go b/sdk/auth/cursor.go new file mode 100644 index 00000000..86cad880 --- /dev/null +++ b/sdk/auth/cursor.go @@ -0,0 +1,91 @@ +package auth + +import ( + "context" + "fmt" + "time" + + cursorauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/cursor" + "github.com/router-for-me/CLIProxyAPI/v6/internal/browser" + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + log "github.com/sirupsen/logrus" +) + +// CursorAuthenticator implements OAuth PKCE login for Cursor. +type CursorAuthenticator struct{} + +// NewCursorAuthenticator constructs a new Cursor authenticator. +func NewCursorAuthenticator() Authenticator { + return &CursorAuthenticator{} +} + +// Provider returns the provider key for cursor. +func (CursorAuthenticator) Provider() string { + return "cursor" +} + +// RefreshLead returns the time before expiry when a refresh should be attempted. +func (CursorAuthenticator) RefreshLead() *time.Duration { + d := 10 * time.Minute + return &d +} + +// Login initiates the Cursor PKCE authentication flow. +func (a CursorAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) { + if cfg == nil { + return nil, fmt.Errorf("cursor auth: configuration is required") + } + if opts == nil { + opts = &LoginOptions{} + } + + // Generate PKCE auth parameters + authParams, err := cursorauth.GenerateAuthParams() + if err != nil { + return nil, fmt.Errorf("cursor: failed to generate auth params: %w", err) + } + + // Display the login URL + fmt.Println("Starting Cursor authentication...") + fmt.Printf("\nPlease visit this URL to log in:\n%s\n\n", authParams.LoginURL) + + // Try to open the browser automatically + if !opts.NoBrowser { + if browser.IsAvailable() { + if errOpen := browser.OpenURL(authParams.LoginURL); errOpen != nil { + log.Warnf("Failed to open browser automatically: %v", errOpen) + } + } + } + + fmt.Println("Waiting for Cursor authorization...") + + // Poll for the auth result + tokens, err := cursorauth.PollForAuth(ctx, authParams.UUID, authParams.Verifier) + if err != nil { + return nil, fmt.Errorf("cursor: authentication failed: %w", err) + } + + expiresAt := cursorauth.GetTokenExpiry(tokens.AccessToken) + + fmt.Println("\nCursor authentication successful!") + + metadata := map[string]any{ + "type": "cursor", + "access_token": tokens.AccessToken, + "refresh_token": tokens.RefreshToken, + "expires_at": expiresAt.Format(time.RFC3339), + "timestamp": time.Now().UnixMilli(), + } + + fileName := "cursor.json" + + return &coreauth.Auth{ + ID: fileName, + Provider: a.Provider(), + FileName: fileName, + Label: "cursor-user", + Metadata: metadata, + }, nil +} diff --git a/sdk/auth/refresh_registry.go b/sdk/auth/refresh_registry.go index 651ba540..59c58bee 100644 --- a/sdk/auth/refresh_registry.go +++ b/sdk/auth/refresh_registry.go @@ -19,6 +19,7 @@ func init() { registerRefreshLead("github-copilot", func() Authenticator { return NewGitHubCopilotAuthenticator() }) registerRefreshLead("gitlab", func() Authenticator { return NewGitLabAuthenticator() }) registerRefreshLead("codebuddy", func() Authenticator { return NewCodeBuddyAuthenticator() }) + registerRefreshLead("cursor", func() Authenticator { return NewCursorAuthenticator() }) } func registerRefreshLead(provider string, factory func() Authenticator) { diff --git a/sdk/cliproxy/auth/scheduler.go b/sdk/cliproxy/auth/scheduler.go index bfff53bf..a9c51405 100644 --- a/sdk/cliproxy/auth/scheduler.go +++ b/sdk/cliproxy/auth/scheduler.go @@ -545,6 +545,11 @@ func (m *scheduledAuthMeta) supportsModel(modelKey string) bool { if modelKey == "" { return true } + // Cursor acts as a universal proxy supporting multiple model families. + // Allow any model to be routed to cursor auth. + if m.providerKey == "cursor" { + return true + } if len(m.supportedModelSet) == 0 { return false } diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index b2e83592..6b5e2042 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -441,6 +441,8 @@ func (s *Service) ensureExecutorsForAuthWithMode(a *coreauth.Auth, forceReplace s.coreManager.RegisterExecutor(executor.NewKiroExecutor(s.cfg)) case "kilo": s.coreManager.RegisterExecutor(executor.NewKiloExecutor(s.cfg)) + case "cursor": + s.coreManager.RegisterExecutor(executor.NewCursorExecutor(s.cfg)) case "github-copilot": s.coreManager.RegisterExecutor(executor.NewGitHubCopilotExecutor(s.cfg)) case "codebuddy": @@ -942,6 +944,11 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { case "kimi": models = registry.GetKimiModels() models = applyExcludedModels(models, excluded) + case "cursor": + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + models = executor.FetchCursorModels(ctx, a, s.cfg) + models = applyExcludedModels(models, excluded) case "github-copilot": ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() diff --git a/test_cursor.sh b/test_cursor.sh new file mode 100755 index 00000000..985685fa --- /dev/null +++ b/test_cursor.sh @@ -0,0 +1,309 @@ +#!/bin/bash +# Test script for Cursor proxy integration +# Usage: +# ./test_cursor.sh login - Login to Cursor (opens browser) +# ./test_cursor.sh start - Build and start the server +# ./test_cursor.sh test - Run API tests against running server +# ./test_cursor.sh all - Login + Start + Test (full flow) + +set -e + +export PATH="/opt/homebrew/bin:$PATH" +export GOROOT="/opt/homebrew/Cellar/go/1.26.1/libexec" + +PROJECT_DIR="/Volumes/Personal/cursor-cli-proxy/CLIProxyAPIPlus" +BINARY="$PROJECT_DIR/cliproxy-test" +API_KEY="quotio-local-D6ABC285-3085-44B4-B872-BD269888811F" +BASE_URL="http://127.0.0.1:8317" +CONFIG="$PROJECT_DIR/config-cursor-test.yaml" +PID_FILE="/tmp/cliproxy-test.pid" + +# Colors +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +NC='\033[0m' + +info() { echo -e "${GREEN}[INFO]${NC} $1"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +error() { echo -e "${RED}[ERROR]${NC} $1"; } + +# --- Build --- +build() { + info "Building CLIProxyAPIPlus..." + cd "$PROJECT_DIR" + go build -o "$BINARY" ./cmd/server/ + info "Build successful: $BINARY" +} + +# --- Create test config --- +create_config() { + cat > "$CONFIG" << 'EOF' +host: '127.0.0.1' +port: 8317 +auth-dir: '~/.cli-proxy-api' +api-keys: + - 'quotio-local-D6ABC285-3085-44B4-B872-BD269888811F' +debug: true +EOF + info "Test config created: $CONFIG" +} + +# --- Login --- +do_login() { + build + create_config + info "Starting Cursor login (will open browser)..." + "$BINARY" --config "$CONFIG" --cursor-login +} + +# --- Start server --- +start_server() { + # Kill any existing instance + stop_server 2>/dev/null || true + + build + create_config + + info "Starting server on port 8317..." + "$BINARY" --config "$CONFIG" & + SERVER_PID=$! + echo "$SERVER_PID" > "$PID_FILE" + info "Server started (PID: $SERVER_PID)" + + # Wait for server to be ready + info "Waiting for server to be ready..." + for i in $(seq 1 15); do + if curl -s "$BASE_URL/v1/models" -H "Authorization: Bearer $API_KEY" > /dev/null 2>&1; then + info "Server is ready!" + return 0 + fi + sleep 1 + done + error "Server failed to start within 15 seconds" + return 1 +} + +# --- Stop server --- +stop_server() { + if [ -f "$PID_FILE" ]; then + PID=$(cat "$PID_FILE") + if kill -0 "$PID" 2>/dev/null; then + info "Stopping server (PID: $PID)..." + kill "$PID" + rm -f "$PID_FILE" + fi + fi + # Also kill any stale process on port 8317 + lsof -ti:8317 2>/dev/null | xargs kill 2>/dev/null || true +} + +# --- Test: List models --- +test_models() { + info "Testing GET /v1/models (looking for cursor models)..." + RESPONSE=$(curl -s "$BASE_URL/v1/models" \ + -H "Authorization: Bearer $API_KEY") + + CURSOR_MODELS=$(echo "$RESPONSE" | python3 -c " +import json, sys +try: + data = json.load(sys.stdin) + models = [m['id'] for m in data.get('data', []) if m.get('owned_by') == 'cursor' or m.get('type') == 'cursor'] + if models: + print('\n'.join(models)) + else: + print('NONE') +except: + print('ERROR') +" 2>/dev/null || echo "PARSE_ERROR") + + if [ "$CURSOR_MODELS" = "NONE" ] || [ "$CURSOR_MODELS" = "ERROR" ] || [ "$CURSOR_MODELS" = "PARSE_ERROR" ]; then + warn "No cursor models found. Have you run '--cursor-login' first?" + echo " Response preview: $(echo "$RESPONSE" | head -c 200)" + return 1 + else + info "Found cursor models:" + echo "$CURSOR_MODELS" | while read -r model; do + echo " - $model" + done + return 0 + fi +} + +# --- Test: Chat completion (streaming) --- +test_chat_stream() { + local model="${1:-cursor-small}" + info "Testing POST /v1/chat/completions (stream, model=$model)..." + + RESPONSE=$(curl -s --max-time 30 "$BASE_URL/v1/chat/completions" \ + -H "Authorization: Bearer $API_KEY" \ + -H "Content-Type: application/json" \ + -d "{ + \"model\": \"$model\", + \"messages\": [{\"role\": \"user\", \"content\": \"Say hello in exactly 3 words.\"}], + \"stream\": true + }" 2>&1) + + # Check if we got SSE data + if echo "$RESPONSE" | grep -q "data:"; then + # Extract content from SSE chunks + CONTENT=$(echo "$RESPONSE" | grep "^data: " | grep -v "\[DONE\]" | while read -r line; do + echo "${line#data: }" | python3 -c " +import json, sys +try: + chunk = json.load(sys.stdin) + delta = chunk.get('choices', [{}])[0].get('delta', {}) + content = delta.get('content', '') + if content: + sys.stdout.write(content) +except: + pass +" 2>/dev/null + done) + + if [ -n "$CONTENT" ]; then + info "Stream response received:" + echo " Content: $CONTENT" + return 0 + else + warn "Got SSE chunks but no content extracted" + echo " Raw (first 500 chars): $(echo "$RESPONSE" | head -c 500)" + return 1 + fi + else + error "No SSE data received" + echo " Response: $(echo "$RESPONSE" | head -c 300)" + return 1 + fi +} + +# --- Test: Chat completion (non-streaming) --- +test_chat_nonstream() { + local model="${1:-cursor-small}" + info "Testing POST /v1/chat/completions (non-stream, model=$model)..." + + RESPONSE=$(curl -s --max-time 30 "$BASE_URL/v1/chat/completions" \ + -H "Authorization: Bearer $API_KEY" \ + -H "Content-Type: application/json" \ + -d "{ + \"model\": \"$model\", + \"messages\": [{\"role\": \"user\", \"content\": \"What is 2+2? Answer with just the number.\"}], + \"stream\": false + }" 2>&1) + + CONTENT=$(echo "$RESPONSE" | python3 -c " +import json, sys +try: + data = json.load(sys.stdin) + content = data['choices'][0]['message']['content'] + print(content) +except Exception as e: + print(f'ERROR: {e}') +" 2>/dev/null || echo "PARSE_ERROR") + + if echo "$CONTENT" | grep -q "ERROR\|PARSE_ERROR"; then + error "Non-streaming request failed" + echo " Response: $(echo "$RESPONSE" | head -c 300)" + return 1 + else + info "Non-stream response received:" + echo " Content: $CONTENT" + return 0 + fi +} + +# --- Run all tests --- +run_tests() { + local passed=0 + local failed=0 + + echo "" + echo "=========================================" + echo " Cursor Proxy Integration Tests" + echo "=========================================" + echo "" + + # Test 1: Models + if test_models; then + ((passed++)) + else + ((failed++)) + fi + echo "" + + # Test 2: Streaming chat + if test_chat_stream "cursor-small"; then + ((passed++)) + else + ((failed++)) + fi + echo "" + + # Test 3: Non-streaming chat + if test_chat_nonstream "cursor-small"; then + ((passed++)) + else + ((failed++)) + fi + echo "" + + echo "=========================================" + echo " Results: ${passed} passed, ${failed} failed" + echo "=========================================" + + [ "$failed" -eq 0 ] +} + +# --- Cleanup --- +cleanup() { + stop_server + rm -f "$BINARY" "$CONFIG" + info "Cleaned up." +} + +# --- Main --- +case "${1:-help}" in + login) + do_login + ;; + start) + start_server + info "Server running. Use './test_cursor.sh test' to run tests." + info "Use './test_cursor.sh stop' to stop." + ;; + stop) + stop_server + ;; + test) + run_tests + ;; + all) + info "=== Full flow: login -> start -> test ===" + echo "" + info "Step 1: Login to Cursor" + do_login + echo "" + info "Step 2: Start server" + start_server + echo "" + info "Step 3: Run tests" + sleep 2 + run_tests + echo "" + info "Step 4: Cleanup" + stop_server + ;; + clean) + cleanup + ;; + *) + echo "Usage: $0 {login|start|stop|test|all|clean}" + echo "" + echo " login - Authenticate with Cursor (opens browser)" + echo " start - Build and start the proxy server" + echo " stop - Stop the running server" + echo " test - Run API tests against running server" + echo " all - Full flow: login + start + test" + echo " clean - Stop server and remove artifacts" + ;; +esac From c1083cbfc61d4ebe8762c6f898a68b04e5fb5c99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=A7=9C=E6=81=92?= Date: Wed, 25 Mar 2026 17:03:14 +0800 Subject: [PATCH 02/13] fix(cursor): MCP tool call resume, H2 flow control, and token usage - Rewrite tool call mechanism from interrupt-resume to inline-wait mode: processH2SessionFrames no longer exits on mcpArgs; instead blocks on toolResultCh while continuing to handle KV/heartbeat messages, then sends MCP result and continues processing text in the same goroutine. Fixes the issue where server stopped generating text after resume. - Add switchable output channel (outMu/currentOut) so first HTTP response closes after tool_calls+[DONE], and resumed text goes to a new channel returned by resumeWithToolResults. Reset streamParam on switch so Translator produces fresh message_start/content_block_start events. - Implement send-side H2 flow control: track server's initial window size and WINDOW_UPDATE increments; Write() blocks when window exhausted. Fixes RST_STREAM FLOW_CONTROL_ERROR on large requests (178KB+). - Decode new InteractionUpdate fields: TurnEndedUpdate (field 14) as stream termination signal, HeartbeatUpdate (field 13) silently ignored, TokenDeltaUpdate (field 8) for token usage tracking. - Include token usage in final stop chunk (prompt_tokens estimated from payload size, completion_tokens from accumulated TokenDeltaUpdate deltas) so Claude CLI status bar shows non-zero token counts. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/auth/cursor/proto/decode.go | 52 ++ internal/auth/cursor/proto/h2stream.go | 144 ++++-- internal/runtime/executor/cursor_executor.go | 504 ++++++++++--------- 3 files changed, 399 insertions(+), 301 deletions(-) diff --git a/internal/auth/cursor/proto/decode.go b/internal/auth/cursor/proto/decode.go index cc10d483..898ca932 100644 --- a/internal/auth/cursor/proto/decode.go +++ b/internal/auth/cursor/proto/decode.go @@ -32,6 +32,9 @@ const ( ServerMsgExecBgShellSpawn // Rejected: background shell ServerMsgExecWriteShellStdin // Rejected: write shell stdin ServerMsgExecOther // Other exec types (respond with empty) + ServerMsgTurnEnded // Turn has ended (no more output) + ServerMsgHeartbeat // Server heartbeat + ServerMsgTokenDelta // Token usage delta ) // DecodedServerMessage holds parsed data from an AgentServerMessage. @@ -63,6 +66,9 @@ type DecodedServerMessage struct { // For other exec - the raw field number for building a response ExecFieldNumber int + + // For TokenDeltaUpdate + TokenDelta int64 } // DecodeAgentServerMessage parses an AgentServerMessage and returns @@ -160,6 +166,24 @@ func decodeInteractionUpdate(data []byte, msg *DecodedServerMessage) { case 3: // tool_call_completed - ignore but log log.Debugf("decodeInteractionUpdate: ToolCallCompleted (ignored)") + case 8: + // token_delta - extract token count + msg.Type = ServerMsgTokenDelta + msg.TokenDelta = decodeVarintField(val, 1) + log.Debugf("decodeInteractionUpdate: TokenDeltaUpdate tokens=%d", msg.TokenDelta) + case 13: + // heartbeat from server + msg.Type = ServerMsgHeartbeat + case 14: + // turn_ended - critical: model finished generating + msg.Type = ServerMsgTurnEnded + log.Debugf("decodeInteractionUpdate: TurnEndedUpdate - stream should end") + case 16: + // step_started - ignore + log.Debugf("decodeInteractionUpdate: StepStartedUpdate (ignored)") + case 17: + // step_completed - ignore + log.Debugf("decodeInteractionUpdate: StepCompletedUpdate (ignored)") default: log.Debugf("decodeInteractionUpdate: unknown field %d", num) } @@ -500,6 +524,34 @@ func decodeBytesField(data []byte, targetField protowire.Number) []byte { return nil } +// decodeVarintField extracts an int64 from the first matching varint field in a submessage. +func decodeVarintField(data []byte, targetField protowire.Number) int64 { + for len(data) > 0 { + num, typ, n := protowire.ConsumeTag(data) + if n < 0 { + return 0 + } + data = data[n:] + if typ == protowire.VarintType { + val, n := protowire.ConsumeVarint(data) + if n < 0 { + return 0 + } + data = data[n:] + if num == targetField { + return int64(val) + } + } else { + n := protowire.ConsumeFieldValue(num, typ, data) + if n < 0 { + return 0 + } + data = data[n:] + } + } + return 0 +} + // BlobIdHex returns the hex string of a blob ID for use as a map key. func BlobIdHex(blobId []byte) string { return hex.EncodeToString(blobId) diff --git a/internal/auth/cursor/proto/h2stream.go b/internal/auth/cursor/proto/h2stream.go index d08d099e..be3f7905 100644 --- a/internal/auth/cursor/proto/h2stream.go +++ b/internal/auth/cursor/proto/h2stream.go @@ -13,6 +13,11 @@ import ( "golang.org/x/net/http2/hpack" ) +const ( + defaultInitialWindowSize = 65535 // HTTP/2 default + maxFramePayload = 16384 // HTTP/2 default max frame size +) + // H2Stream provides bidirectional HTTP/2 streaming for the Connect protocol. // Go's net/http does not support full-duplex HTTP/2, so we use the low-level framer. type H2Stream struct { @@ -21,11 +26,17 @@ type H2Stream struct { streamID uint32 mu sync.Mutex id string // unique identifier for debugging - frameNum int64 // sequential frame counter for debugging + frameNum int64 // sequential frame counter for debugging dataCh chan []byte doneCh chan struct{} err error + + // Send-side flow control + sendWindow int32 // available bytes we can send on this stream + connWindow int32 // available bytes on the connection level + windowCond *sync.Cond // signaled when window is updated + windowMu sync.Mutex // protects sendWindow, connWindow } // ID returns the unique identifier for this stream (for logging). @@ -59,7 +70,7 @@ func DialH2Stream(host string, headers map[string]string) (*H2Stream, error) { return nil, fmt.Errorf("h2: preface write failed: %w", err) } - // Send initial SETTINGS (with large initial window) + // Send initial SETTINGS (tell server how much WE can receive) if err := framer.WriteSettings( http2.Setting{ID: http2.SettingInitialWindowSize, Val: 4 * 1024 * 1024}, http2.Setting{ID: http2.SettingMaxConcurrentStreams, Val: 100}, @@ -68,14 +79,17 @@ func DialH2Stream(host string, headers map[string]string) (*H2Stream, error) { return nil, fmt.Errorf("h2: settings write failed: %w", err) } - // Connection-level window update (default is 65535, bump it up) + // Connection-level window update (for receiving) if err := framer.WriteWindowUpdate(0, 3*1024*1024); err != nil { tlsConn.Close() return nil, fmt.Errorf("h2: window update failed: %w", err) } // Read and handle initial server frames (SETTINGS, WINDOW_UPDATE) - for i := 0; i < 5; i++ { + // Track server's initial window size (how much WE can send) + serverInitialWindowSize := int32(defaultInitialWindowSize) + connWindowSize := int32(defaultInitialWindowSize) // connection-level send window + for i := 0; i < 10; i++ { f, err := framer.ReadFrame() if err != nil { tlsConn.Close() @@ -84,12 +98,22 @@ func DialH2Stream(host string, headers map[string]string) (*H2Stream, error) { switch sf := f.(type) { case *http2.SettingsFrame: if !sf.IsAck() { + sf.ForeachSetting(func(s http2.Setting) error { + if s.ID == http2.SettingInitialWindowSize { + serverInitialWindowSize = int32(s.Val) + log.Debugf("h2: server initial window size: %d", s.Val) + } + return nil + }) framer.WriteSettingsAck() } else { goto handshakeDone } case *http2.WindowUpdateFrame: - // ignore + if sf.StreamID == 0 { + connWindowSize += int32(sf.Increment) + log.Debugf("h2: initial conn window update: +%d, total=%d", sf.Increment, connWindowSize) + } default: // unexpected but continue } @@ -124,36 +148,53 @@ handshakeDone: } s := &H2Stream{ - framer: framer, - conn: tlsConn, - streamID: streamID, - dataCh: make(chan []byte, 256), - doneCh: make(chan struct{}), - id: fmt.Sprintf("%d-%s", streamID, time.Now().Format("150405.000")), - frameNum: 0, + framer: framer, + conn: tlsConn, + streamID: streamID, + dataCh: make(chan []byte, 256), + doneCh: make(chan struct{}), + id: fmt.Sprintf("%d-%s", streamID, time.Now().Format("150405.000")), + frameNum: 0, + sendWindow: serverInitialWindowSize, + connWindow: connWindowSize, } + s.windowCond = sync.NewCond(&s.windowMu) go s.readLoop() return s, nil } -// Write sends a DATA frame on the stream. +// Write sends a DATA frame on the stream, respecting flow control. func (s *H2Stream) Write(data []byte) error { - s.mu.Lock() - defer s.mu.Unlock() - const maxFrame = 16384 for len(data) > 0 { chunk := data - if len(chunk) > maxFrame { - chunk = data[:maxFrame] + if len(chunk) > maxFramePayload { + chunk = data[:maxFramePayload] } - data = data[len(chunk):] - if err := s.framer.WriteData(s.streamID, false, chunk); err != nil { + + // Wait for flow control window + s.windowMu.Lock() + for s.sendWindow <= 0 || s.connWindow <= 0 { + s.windowCond.Wait() + } + // Limit chunk to available window + allowed := int(s.sendWindow) + if int(s.connWindow) < allowed { + allowed = int(s.connWindow) + } + if len(chunk) > allowed { + chunk = chunk[:allowed] + } + s.sendWindow -= int32(len(chunk)) + s.connWindow -= int32(len(chunk)) + s.windowMu.Unlock() + + s.mu.Lock() + err := s.framer.WriteData(s.streamID, false, chunk) + s.mu.Unlock() + if err != nil { return err } - } - // Try to flush the underlying connection if it supports it - if flusher, ok := s.conn.(interface{ Flush() error }); ok { - flusher.Flush() + data = data[len(chunk):] } return nil } @@ -167,12 +208,13 @@ func (s *H2Stream) Done() <-chan struct{} { return s.doneCh } // Close tears down the connection. func (s *H2Stream) Close() { s.conn.Close() + // Unblock any writers waiting on flow control + s.windowCond.Broadcast() } func (s *H2Stream) readLoop() { defer close(s.doneCh) defer close(s.dataCh) - log.Debugf("h2stream[%s]: readLoop started for streamID=%d", s.id, s.streamID) for { f, err := s.framer.ReadFrame() @@ -180,71 +222,47 @@ func (s *H2Stream) readLoop() { if err != io.EOF { s.err = err log.Debugf("h2stream[%s]: readLoop error: %v", s.id, err) - } else { - log.Debugf("h2stream[%s]: readLoop EOF", s.id) } return } - // Increment frame counter for debugging + // Increment frame counter s.mu.Lock() s.frameNum++ - frameNum := s.frameNum s.mu.Unlock() switch frame := f.(type) { case *http2.DataFrame: - log.Debugf("h2stream[%s]: frame#%d received DATA frame streamID=%d, len=%d, endStream=%v", s.id, frameNum, frame.StreamID, len(frame.Data()), frame.StreamEnded()) if frame.StreamID == s.streamID && len(frame.Data()) > 0 { cp := make([]byte, len(frame.Data())) copy(cp, frame.Data()) - // Log first 20 bytes for debugging - previewLen := len(cp) - if previewLen > 20 { - previewLen = 20 - } - log.Debugf("h2stream[%s]: frame#%d sending to dataCh: len=%d, dataCh len=%d/%d, first bytes: %x (%q)", s.id, frameNum, len(cp), len(s.dataCh), cap(s.dataCh), cp[:previewLen], string(cp[:previewLen])) s.dataCh <- cp - // Flow control: send WINDOW_UPDATE + // Flow control: send WINDOW_UPDATE for received data s.mu.Lock() s.framer.WriteWindowUpdate(0, uint32(len(cp))) s.framer.WriteWindowUpdate(s.streamID, uint32(len(cp))) s.mu.Unlock() } if frame.StreamEnded() { - log.Debugf("h2stream[%s]: frame#%d DATA frame has END_STREAM flag, stream ending", s.id, frameNum) return } case *http2.HeadersFrame: - // Decode HPACK headers for debugging - decoder := hpack.NewDecoder(4096, func(hf hpack.HeaderField) { - log.Debugf("h2stream[%s]: frame#%d header: %s = %q", s.id, frameNum, hf.Name, hf.Value) - // Check for error status - if hf.Name == "grpc-status" || hf.Name == ":status" && hf.Value != "200" { - log.Warnf("h2stream[%s]: frame#%d received error status header: %s = %q", s.id, frameNum, hf.Name, hf.Value) - } - }) - decoder.Write(frame.HeaderBlockFragment()) - log.Debugf("h2stream[%s]: frame#%d received HEADERS frame streamID=%d, endStream=%v", s.id, frameNum, frame.StreamID, frame.StreamEnded()) if frame.StreamEnded() { - log.Debugf("h2stream[%s]: frame#%d HEADERS frame has END_STREAM flag, stream ending", s.id, frameNum) return } case *http2.RSTStreamFrame: s.err = fmt.Errorf("h2: RST_STREAM code=%d", frame.ErrCode) - log.Debugf("h2stream[%s]: frame#%d received RST_STREAM code=%d", s.id, frameNum, frame.ErrCode) + log.Debugf("h2stream[%s]: received RST_STREAM code=%d", s.id, frame.ErrCode) return case *http2.GoAwayFrame: s.err = fmt.Errorf("h2: GOAWAY code=%d", frame.ErrCode) - log.Debugf("h2stream[%s]: received GOAWAY code=%d", s.id, frame.ErrCode) return case *http2.PingFrame: - log.Debugf("h2stream[%s]: received PING frame, isAck=%v", s.id, frame.IsAck()) if !frame.IsAck() { s.mu.Lock() s.framer.WritePing(true, frame.Data) @@ -252,15 +270,33 @@ func (s *H2Stream) readLoop() { } case *http2.SettingsFrame: - log.Debugf("h2stream[%s]: received SETTINGS frame, isAck=%v, numSettings=%d", s.id, frame.IsAck(), frame.NumSettings()) if !frame.IsAck() { + // Check for window size changes + frame.ForeachSetting(func(setting http2.Setting) error { + if setting.ID == http2.SettingInitialWindowSize { + s.windowMu.Lock() + delta := int32(setting.Val) - s.sendWindow + s.sendWindow += delta + s.windowMu.Unlock() + s.windowCond.Broadcast() + } + return nil + }) s.mu.Lock() s.framer.WriteSettingsAck() s.mu.Unlock() } case *http2.WindowUpdateFrame: - log.Debugf("h2stream[%s]: received WINDOW_UPDATE frame", s.id) + // Update send-side flow control window + s.windowMu.Lock() + if frame.StreamID == 0 { + s.connWindow += int32(frame.Increment) + } else if frame.StreamID == s.streamID { + s.sendWindow += int32(frame.Increment) + } + s.windowMu.Unlock() + s.windowCond.Broadcast() } } } diff --git a/internal/runtime/executor/cursor_executor.go b/internal/runtime/executor/cursor_executor.go index 5519e92d..bba06bc7 100644 --- a/internal/runtime/executor/cursor_executor.go +++ b/internal/runtime/executor/cursor_executor.go @@ -6,7 +6,6 @@ import ( "crypto/sha256" "crypto/tls" "encoding/base64" - "encoding/binary" "encoding/hex" "encoding/json" "fmt" @@ -47,12 +46,15 @@ type CursorExecutor struct { } type cursorSession struct { - stream *cursorproto.H2Stream - blobStore map[string][]byte - mcpTools []cursorproto.McpToolDef - pending []pendingMcpExec - cancel context.CancelFunc // cancels the session-scoped heartbeat (NOT tied to HTTP request) - createdAt time.Time + stream *cursorproto.H2Stream + blobStore map[string][]byte + mcpTools []cursorproto.McpToolDef + pending []pendingMcpExec + cancel context.CancelFunc // cancels the session-scoped heartbeat (NOT tied to HTTP request) + createdAt time.Time + toolResultCh chan []toolResultInfo // receives tool results from the next HTTP request + resumeOutCh chan cliproxyexecutor.StreamChunk // output channel for resumed response + switchOutput func(ch chan cliproxyexecutor.StreamChunk) // callback to switch output channel } type pendingMcpExec struct { @@ -235,6 +237,8 @@ func (e *CursorExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r fullText.WriteString(text) }, nil, + nil, + nil, // tokenUsage - non-streaming ) id := "chatcmpl-" + uuid.New().String()[:28] @@ -341,9 +345,30 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A chatId := "chatcmpl-" + uuid.New().String()[:28] created := time.Now().Unix() - // sendChunk builds an OpenAI SSE line and optionally translates to target format var streamParam any - sendChunk := func(delta string, finishReason string) { + + // Tool result channel for inline mode. processH2SessionFrames blocks on it + // when mcpArgs is received, while continuing to handle KV/heartbeat. + toolResultCh := make(chan []toolResultInfo, 1) + + // Switchable output: initially writes to `chunks`. After mcpArgs, the + // onMcpExec callback closes `chunks` (ending the first HTTP response), + // then processH2SessionFrames blocks on toolResultCh. When results arrive, + // it switches to `resumeOutCh` (created by resumeWithToolResults). + var outMu sync.Mutex + currentOut := chunks + + emitToOut := func(chunk cliproxyexecutor.StreamChunk) { + outMu.Lock() + out := currentOut + outMu.Unlock() + if out != nil { + out <- chunk + } + } + + // Wrap sendChunk/sendDone to use emitToOut + sendChunkSwitchable := func(delta string, finishReason string) { fr := "null" if finishReason != "" { fr = finishReason @@ -355,95 +380,146 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A if needsTranslate { translated := sdktranslator.TranslateStream(ctx, to, from, req.Model, originalPayload, payload, sseLine, &streamParam) for _, t := range translated { - chunks <- cliproxyexecutor.StreamChunk{Payload: bytes.Clone(t)} + emitToOut(cliproxyexecutor.StreamChunk{Payload: bytes.Clone(t)}) } } else { - chunks <- cliproxyexecutor.StreamChunk{Payload: []byte(openaiJSON)} + emitToOut(cliproxyexecutor.StreamChunk{Payload: []byte(openaiJSON)}) } } - sendDone := func() { + sendDoneSwitchable := func() { if needsTranslate { done := sdktranslator.TranslateStream(ctx, to, from, req.Model, originalPayload, payload, []byte("data: [DONE]\n"), &streamParam) for _, d := range done { - chunks <- cliproxyexecutor.StreamChunk{Payload: bytes.Clone(d)} + emitToOut(cliproxyexecutor.StreamChunk{Payload: bytes.Clone(d)}) } } else { - chunks <- cliproxyexecutor.StreamChunk{Payload: []byte("[DONE]")} + emitToOut(cliproxyexecutor.StreamChunk{Payload: []byte("[DONE]")}) } } go func() { - defer close(chunks) - + var resumeOutCh chan cliproxyexecutor.StreamChunk + _ = resumeOutCh thinkingActive := false toolCallIndex := 0 - mcpExecReceived := false + usage := &cursorTokenUsage{} + usage.setInputEstimate(len(payload)) processH2SessionFrames(sessionCtx, stream, params.BlobStore, params.McpTools, func(text string, isThinking bool) { if isThinking { if !thinkingActive { thinkingActive = true - sendChunk(`{"role":"assistant","content":""}`, "") + sendChunkSwitchable(`{"role":"assistant","content":""}`, "") } - sendChunk(fmt.Sprintf(`{"content":%s}`, jsonString(text)), "") + sendChunkSwitchable(fmt.Sprintf(`{"content":%s}`, jsonString(text)), "") } else { if thinkingActive { thinkingActive = false - sendChunk(`{"content":""}`, "") + sendChunkSwitchable(`{"content":""}`, "") } - sendChunk(fmt.Sprintf(`{"content":%s}`, jsonString(text)), "") + sendChunkSwitchable(fmt.Sprintf(`{"content":%s}`, jsonString(text)), "") } }, func(exec pendingMcpExec) { - mcpExecReceived = true if thinkingActive { thinkingActive = false - sendChunk(`{"content":""}`, "") + sendChunkSwitchable(`{"content":""}`, "") } toolCallJSON := fmt.Sprintf(`{"tool_calls":[{"index":%d,"id":"%s","type":"function","function":{"name":"%s","arguments":%s}}]}`, toolCallIndex, exec.ToolCallId, exec.ToolName, jsonString(exec.Args)) toolCallIndex++ - sendChunk(toolCallJSON, "") - sendChunk(`{}`, `"tool_calls"`) - sendDone() + sendChunkSwitchable(toolCallJSON, "") + sendChunkSwitchable(`{}`, `"tool_calls"`) + sendDoneSwitchable() - // Save session for resume — keep stream alive. - // The heartbeat goroutine continues running (session-scoped context), - // keeping the H2 connection alive while the MCP tool executes. - log.Debugf("cursor: saving session %s for MCP tool resume (tool=%s, streamID=%s)", sessionKey, exec.ToolName, stream.ID()) + // Close current output to end the current HTTP SSE response + outMu.Lock() + if currentOut != nil { + close(currentOut) + currentOut = nil + } + outMu.Unlock() + + // Create new resume output channel, reuse the same toolResultCh + resumeOut := make(chan cliproxyexecutor.StreamChunk, 64) + log.Debugf("cursor: saving session %s for MCP tool resume (tool=%s)", sessionKey, exec.ToolName) e.mu.Lock() e.sessions[sessionKey] = &cursorSession{ - stream: stream, - blobStore: params.BlobStore, - mcpTools: params.McpTools, - pending: []pendingMcpExec{exec}, - cancel: sessionCancel, - createdAt: time.Now(), + stream: stream, + blobStore: params.BlobStore, + mcpTools: params.McpTools, + pending: []pendingMcpExec{exec}, + cancel: sessionCancel, + createdAt: time.Now(), + toolResultCh: toolResultCh, // reuse same channel across rounds + resumeOutCh: resumeOut, + switchOutput: func(ch chan cliproxyexecutor.StreamChunk) { + outMu.Lock() + currentOut = ch + // Reset translator state so the new HTTP response gets + // a fresh message_start, content_block_start, etc. + streamParam = nil + // New response needs its own message ID + chatId = "chatcmpl-" + uuid.New().String()[:28] + created = time.Now().Unix() + outMu.Unlock() + }, } e.mu.Unlock() + resumeOutCh = resumeOut + + // processH2SessionFrames will now block on toolResultCh (inline wait loop) + // while continuing to handle KV messages }, + toolResultCh, + usage, ) - if !mcpExecReceived { - if thinkingActive { - sendChunk(`{"content":""}`, "") - } - sendChunk(`{}`, `"stop"`) - sendDone() - sessionCancel() - stream.Close() + // processH2SessionFrames returned — stream is done + if thinkingActive { + sendChunkSwitchable(`{"content":""}`, "") } - // If mcpExecReceived, do NOT close stream or cancel — session resume will handle it + // Include token usage in the final stop chunk + inputTok, outputTok := usage.get() + stopDelta := fmt.Sprintf(`{},"usage":{"prompt_tokens":%d,"completion_tokens":%d,"total_tokens":%d}`, + inputTok, outputTok, inputTok+outputTok) + // Build the stop chunk with usage embedded in the choices array level + fr := `"stop"` + openaiJSON := fmt.Sprintf(`{"id":"%s","object":"chat.completion.chunk","created":%d,"model":"%s","choices":[{"index":0,"delta":{},"finish_reason":%s}],"usage":{"prompt_tokens":%d,"completion_tokens":%d,"total_tokens":%d}}`, + chatId, created, parsed.Model, fr, inputTok, outputTok, inputTok+outputTok) + sseLine := []byte("data: " + openaiJSON + "\n") + if needsTranslate { + translated := sdktranslator.TranslateStream(ctx, to, from, req.Model, originalPayload, payload, sseLine, &streamParam) + for _, t := range translated { + emitToOut(cliproxyexecutor.StreamChunk{Payload: bytes.Clone(t)}) + } + } else { + emitToOut(cliproxyexecutor.StreamChunk{Payload: []byte(openaiJSON)}) + } + sendDoneSwitchable() + _ = stopDelta // unused + + // Close whatever output channel is still active + outMu.Lock() + if currentOut != nil { + close(currentOut) + currentOut = nil + } + outMu.Unlock() + sessionCancel() + stream.Close() }() return &cliproxyexecutor.StreamResult{Chunks: chunks}, nil } -// resumeWithToolResults sends MCP tool results back on the existing H2 stream, -// then continues reading the stream for the model's response. -// Mirrors resumeWithToolResults() in cursor-fetch.ts. +// resumeWithToolResults injects tool results into the running processH2SessionFrames +// via the toolResultCh channel. The original goroutine from ExecuteStream is still alive, +// blocking on toolResultCh. Once we send the results, it sends the MCP result to Cursor +// and continues processing the response text — all in the same goroutine that has been +// handling KV messages the whole time. func (e *CursorExecutor) resumeWithToolResults( ctx context.Context, session *cursorSession, @@ -453,208 +529,29 @@ func (e *CursorExecutor) resumeWithToolResults( originalPayload, payload []byte, needsTranslate bool, ) (*cliproxyexecutor.StreamResult, error) { - stream := session.stream - log.Debugf("cursor: resumeWithToolResults: using stream ID=%s", stream.ID()) + log.Debugf("cursor: resumeWithToolResults: injecting %d tool results via channel", len(parsed.ToolResults)) - // Cancel old session-scoped heartbeat before starting a new one - session.cancel() - - // CRITICAL: Process any pending messages from the channel before sending MCP result. - // After the initial processH2SessionFrames returned (upon receiving MCP args), - // the server may have sent more data (KV messages, text deltas) that are now buffered in dataCh. - // We must process KV messages (respond to them) but discard text deltas (stale responses). - drainedCount := 0 - drainedBytes := 0 - kvProcessedCount := 0 - for { - select { - case staleData, ok := <-stream.Data(): - if !ok { - log.Debugf("cursor: resumeWithToolResults: dataCh closed during drain") - break - } - drainedCount++ - drainedBytes += len(staleData) - log.Debugf("cursor: resumeWithToolResults: processing stale data #%d: len=%d, first bytes: %x (%q)", drainedCount, len(staleData), staleData[:min(20, len(staleData))], string(staleData[:min(20, len(staleData))])) - - // Try to decode and handle KV messages (they need responses) - if len(staleData) > 5 { - frameLen := binary.BigEndian.Uint32(staleData[1:5]) - if int(frameLen)+5 <= len(staleData) { - payload := staleData[5 : 5+frameLen] - msg, err := cursorproto.DecodeAgentServerMessage(payload) - if err == nil && msg.Type == cursorproto.ServerMsgKvGetBlob { - // Respond to KV getBlob - blobKey := cursorproto.BlobIdHex(msg.BlobId) - data := session.blobStore[blobKey] - log.Debugf("cursor: resumeWithToolResults: responding to stale KV getBlob kvId=%d blobKey=%s found=%v", msg.KvId, blobKey, len(data) > 0) - resp := cursorproto.EncodeKvGetBlobResult(msg.KvId, data) - stream.Write(cursorproto.FrameConnectMessage(resp, 0)) - kvProcessedCount++ - continue - } else if err == nil && msg.Type == cursorproto.ServerMsgKvSetBlob { - // Respond to KV setBlob - blobKey := cursorproto.BlobIdHex(msg.BlobId) - session.blobStore[blobKey] = append([]byte(nil), msg.BlobData...) - log.Debugf("cursor: resumeWithToolResults: responding to stale KV setBlob kvId=%d blobKey=%s", msg.KvId, blobKey) - resp := cursorproto.EncodeKvSetBlobResult(msg.KvId) - stream.Write(cursorproto.FrameConnectMessage(resp, 0)) - kvProcessedCount++ - continue - } - } - } - log.Debugf("cursor: resumeWithToolResults: discarding non-KV stale data") - default: - // No more data in channel - goto drainDone - } + if session.toolResultCh == nil { + return nil, fmt.Errorf("cursor: session has no toolResultCh (stale session?)") } -drainDone: - if drainedCount > 0 { - log.Debugf("cursor: resumeWithToolResults: processed %d stale frames (%d bytes total, %d KV responded)", drainedCount, drainedBytes, kvProcessedCount) + if session.resumeOutCh == nil { + return nil, fmt.Errorf("cursor: session has no resumeOutCh") } - // Send MCP results back on the same H2 stream - for _, exec := range session.pending { - var content string - var isError bool - found := false - for _, tr := range parsed.ToolResults { - if tr.ToolCallId == exec.ToolCallId { - content = tr.Content - found = true - break - } - } - if !found { - content = "Tool result not provided" - isError = true - } - log.Debugf("cursor: sending MCP result for tool=%s callId=%s execMsgId=%d execId=%s contentLen=%d isError=%v", - exec.ToolName, exec.ToolCallId, exec.ExecMsgId, exec.ExecId, len(content), isError) - resultBytes := cursorproto.EncodeExecMcpResult(exec.ExecMsgId, exec.ExecId, content, isError) - framedResult := cursorproto.FrameConnectMessage(resultBytes, 0) - // Log the framed result details for debugging - log.Debugf("cursor: MCP result frame size=%d bytes", len(framedResult)) - log.Debugf("cursor: MCP result frame header: flags=%d, len=%d", framedResult[0], binary.BigEndian.Uint32(framedResult[1:5])) - log.Debugf("cursor: MCP result protobuf hex (first 50 bytes): %x", resultBytes[:min(50, len(resultBytes))]) - if err := stream.Write(framedResult); err != nil { - stream.Close() - return nil, fmt.Errorf("cursor: failed to send MCP result: %w", err) - } - log.Debugf("cursor: MCP result sent successfully for tool=%s", exec.ToolName) + log.Debugf("cursor: resumeWithToolResults: switching output to resumeOutCh and injecting results") + + // Switch the output channel BEFORE injecting results, so that when + // processH2SessionFrames unblocks and starts emitting text, it writes + // to the resumeOutCh which the new HTTP handler is reading from. + if session.switchOutput != nil { + session.switchOutput(session.resumeOutCh) } - // Start new session-scoped heartbeat (independent of HTTP request context) - sessionCtx, sessionCancel := context.WithCancel(context.Background()) - go cursorH2Heartbeat(sessionCtx, stream) - log.Debugf("cursor: started new heartbeat for resumed session, waiting for Cursor response...") + // Inject tool results — this unblocks the waiting processH2SessionFrames + session.toolResultCh <- parsed.ToolResults - chunks := make(chan cliproxyexecutor.StreamChunk, 64) - chatId := "chatcmpl-" + uuid.New().String()[:28] - created := time.Now().Unix() - sessionKey := deriveSessionKey(parsed.Model, parsed.Messages) - - var streamParam any - sendChunk := func(delta string, finishReason string) { - fr := "null" - if finishReason != "" { - fr = finishReason - } - openaiJSON := fmt.Sprintf(`{"id":"%s","object":"chat.completion.chunk","created":%d,"model":"%s","choices":[{"index":0,"delta":%s,"finish_reason":%s}]}`, - chatId, created, parsed.Model, delta, fr) - sseLine := []byte("data: " + openaiJSON + "\n") - - if needsTranslate { - translated := sdktranslator.TranslateStream(ctx, to, from, req.Model, originalPayload, payload, sseLine, &streamParam) - for _, t := range translated { - chunks <- cliproxyexecutor.StreamChunk{Payload: bytes.Clone(t)} - } - } else { - chunks <- cliproxyexecutor.StreamChunk{Payload: []byte(openaiJSON)} - } - } - - sendDone := func() { - if needsTranslate { - done := sdktranslator.TranslateStream(ctx, to, from, req.Model, originalPayload, payload, []byte("data: [DONE]\n"), &streamParam) - for _, d := range done { - chunks <- cliproxyexecutor.StreamChunk{Payload: bytes.Clone(d)} - } - } else { - chunks <- cliproxyexecutor.StreamChunk{Payload: []byte("[DONE]")} - } - } - - go func() { - defer func() { - log.Debugf("cursor: resume goroutine exiting, closing chunks channel") - close(chunks) - }() - log.Debugf("cursor: resume goroutine started, entering processH2SessionFrames") - - thinkingActive := false - toolCallIndex := 0 - mcpExecReceived := false - - processH2SessionFrames(sessionCtx, stream, session.blobStore, session.mcpTools, - func(text string, isThinking bool) { - log.Debugf("cursor: resume received text (isThinking=%v, len=%d)", isThinking, len(text)) - if isThinking { - if !thinkingActive { - thinkingActive = true - sendChunk(`{"role":"assistant","content":""}`, "") - } - sendChunk(fmt.Sprintf(`{"content":%s}`, jsonString(text)), "") - } else { - if thinkingActive { - thinkingActive = false - sendChunk(`{"content":""}`, "") - } - sendChunk(fmt.Sprintf(`{"content":%s}`, jsonString(text)), "") - } - }, - func(exec pendingMcpExec) { - mcpExecReceived = true - if thinkingActive { - thinkingActive = false - sendChunk(`{"content":""}`, "") - } - toolCallJSON := fmt.Sprintf(`{"tool_calls":[{"index":%d,"id":"%s","type":"function","function":{"name":"%s","arguments":%s}}]}`, - toolCallIndex, exec.ToolCallId, exec.ToolName, jsonString(exec.Args)) - toolCallIndex++ - sendChunk(toolCallJSON, "") - sendChunk(`{}`, `"tool_calls"`) - sendDone() - - // Save session again for another round of tool calls - log.Debugf("cursor: saving session %s for another MCP tool resume (tool=%s, streamID=%s)", sessionKey, exec.ToolName, stream.ID()) - e.mu.Lock() - e.sessions[sessionKey] = &cursorSession{ - stream: stream, - blobStore: session.blobStore, - mcpTools: session.mcpTools, - pending: []pendingMcpExec{exec}, - cancel: sessionCancel, - createdAt: time.Now(), - } - e.mu.Unlock() - }, - ) - - if !mcpExecReceived { - if thinkingActive { - sendChunk(`{"content":""}`, "") - } - sendChunk(`{}`, `"stop"`) - sendDone() - sessionCancel() - stream.Close() - } - }() - - return &cliproxyexecutor.StreamResult{Chunks: chunks}, nil + // Return the resumeOutCh for the new HTTP handler to read from + return &cliproxyexecutor.StreamResult{Chunks: session.resumeOutCh}, nil } // --- H2Stream helpers --- @@ -693,6 +590,35 @@ func cursorH2Heartbeat(ctx context.Context, stream *cursorproto.H2Stream) { // --- Response processing --- +// cursorTokenUsage tracks token counts from Cursor's TokenDeltaUpdate messages. +type cursorTokenUsage struct { + mu sync.Mutex + outputTokens int64 + inputTokensEst int64 // estimated from request payload size +} + +func (u *cursorTokenUsage) addOutput(delta int64) { + u.mu.Lock() + defer u.mu.Unlock() + u.outputTokens += delta +} + +func (u *cursorTokenUsage) setInputEstimate(payloadBytes int) { + u.mu.Lock() + defer u.mu.Unlock() + // Rough estimate: ~4 bytes per token for mixed content + u.inputTokensEst = int64(payloadBytes / 4) + if u.inputTokensEst < 1 { + u.inputTokensEst = 1 + } +} + +func (u *cursorTokenUsage) get() (input, output int64) { + u.mu.Lock() + defer u.mu.Unlock() + return u.inputTokensEst, u.outputTokens +} + func processH2SessionFrames( ctx context.Context, stream *cursorproto.H2Stream, @@ -700,6 +626,8 @@ func processH2SessionFrames( mcpTools []cursorproto.McpToolDef, onText func(text string, isThinking bool), onMcpExec func(exec pendingMcpExec), + toolResultCh <-chan []toolResultInfo, // nil for no tool result injection; non-nil to wait for results + tokenUsage *cursorTokenUsage, // tracks accumulated token usage (may be nil) ) { var buf bytes.Buffer rejectReason := "Tool not available in this environment. Use the MCP tools provided instead." @@ -762,6 +690,20 @@ func processH2SessionFrames( case cursorproto.ServerMsgThinkingCompleted: // Handled by caller + case cursorproto.ServerMsgTurnEnded: + log.Debugf("cursor: TurnEnded received, stream will finish") + return + + case cursorproto.ServerMsgHeartbeat: + // Server heartbeat, ignore silently + continue + + case cursorproto.ServerMsgTokenDelta: + if tokenUsage != nil && msg.TokenDelta > 0 { + tokenUsage.addOutput(msg.TokenDelta) + } + continue + case cursorproto.ServerMsgKvGetBlob: blobKey := cursorproto.BlobIdHex(msg.BlobId) data := blobStore[blobKey] @@ -785,17 +727,85 @@ func processH2SessionFrames( if toolCallId == "" { toolCallId = uuid.New().String() } - // Debug: log the received execId from server log.Debugf("cursor: received mcpArgs from server: execMsgId=%d execId=%q toolName=%s toolCallId=%s", msg.ExecMsgId, msg.ExecId, msg.McpToolName, toolCallId) - onMcpExec(pendingMcpExec{ + pending := pendingMcpExec{ ExecMsgId: msg.ExecMsgId, ExecId: msg.ExecId, ToolCallId: toolCallId, ToolName: msg.McpToolName, Args: decodedArgs, - }) - return + } + onMcpExec(pending) + + if toolResultCh == nil { + return + } + + // Inline mode: wait for tool result while handling KV/heartbeat + log.Debugf("cursor: waiting for tool result on channel (inline mode)...") + var toolResults []toolResultInfo + waitLoop: + for { + select { + case <-ctx.Done(): + return + case results, ok := <-toolResultCh: + if !ok { + return + } + toolResults = results + break waitLoop + case waitData, ok := <-stream.Data(): + if !ok { + return + } + buf.Write(waitData) + for { + cb := buf.Bytes() + if len(cb) == 0 { + break + } + wf, wp, wc, wok := cursorproto.ParseConnectFrame(cb) + if !wok { + break + } + buf.Next(wc) + if wf&cursorproto.ConnectEndStreamFlag != 0 { + continue + } + wmsg, werr := cursorproto.DecodeAgentServerMessage(wp) + if werr != nil { + continue + } + switch wmsg.Type { + case cursorproto.ServerMsgKvGetBlob: + blobKey := cursorproto.BlobIdHex(wmsg.BlobId) + d := blobStore[blobKey] + stream.Write(cursorproto.FrameConnectMessage(cursorproto.EncodeKvGetBlobResult(wmsg.KvId, d), 0)) + case cursorproto.ServerMsgKvSetBlob: + blobKey := cursorproto.BlobIdHex(wmsg.BlobId) + blobStore[blobKey] = append([]byte(nil), wmsg.BlobData...) + stream.Write(cursorproto.FrameConnectMessage(cursorproto.EncodeKvSetBlobResult(wmsg.KvId), 0)) + case cursorproto.ServerMsgExecRequestCtx: + stream.Write(cursorproto.FrameConnectMessage(cursorproto.EncodeExecRequestContextResult(wmsg.ExecMsgId, wmsg.ExecId, mcpTools), 0)) + } + } + case <-stream.Done(): + return + } + } + + // Send MCP result + for _, tr := range toolResults { + if tr.ToolCallId == pending.ToolCallId { + log.Debugf("cursor: sending inline MCP result for tool=%s", pending.ToolName) + resultBytes := cursorproto.EncodeExecMcpResult(pending.ExecMsgId, pending.ExecId, tr.Content, false) + stream.Write(cursorproto.FrameConnectMessage(resultBytes, 0)) + break + } + } + continue } case cursorproto.ServerMsgExecReadArgs: From 8afef438876eed4ad6e0f33c916a58fb8dfbc108 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=A7=9C=E6=81=92?= Date: Wed, 25 Mar 2026 17:15:24 +0800 Subject: [PATCH 03/13] fix(cursor): preserve tool call context in multi-turn conversations When an assistant message appears after tool results without a pending user message, append it to the last turn's assistant text instead of dropping it. Also add bakeToolResultsIntoTurns() to merge tool results into turn context when no active H2 session exists for resume, ensuring the model sees the full tool interaction history in follow-up requests. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/runtime/executor/cursor_executor.go | 40 +++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/internal/runtime/executor/cursor_executor.go b/internal/runtime/executor/cursor_executor.go index bba06bc7..3debf73c 100644 --- a/internal/runtime/executor/cursor_executor.go +++ b/internal/runtime/executor/cursor_executor.go @@ -321,6 +321,13 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A } e.mu.Unlock() + // If tool results exist but no session to resume, bake them into turns + // so the model sees tool interaction context in the new conversation. + if len(parsed.ToolResults) > 0 { + log.Debugf("cursor: no session to resume, baking %d tool results into turns", len(parsed.ToolResults)) + bakeToolResultsIntoTurns(parsed) + } + params := buildRunRequestParams(parsed) requestBytes := cursorproto.EncodeRunRequest(params) framedRequest := cursorproto.FrameConnectMessage(requestBytes, 0) @@ -898,12 +905,22 @@ func parseOpenAIRequest(payload []byte) *parsedOpenAIRequest { pendingUser = extractTextContent(msg.Get("content")) p.Images = extractImages(msg.Get("content")) case "assistant": + assistantText := extractTextContent(msg.Get("content")) if pendingUser != "" { p.Turns = append(p.Turns, cursorproto.TurnData{ UserText: pendingUser, - AssistantText: extractTextContent(msg.Get("content")), + AssistantText: assistantText, }) pendingUser = "" + } else if len(p.Turns) > 0 && assistantText != "" { + // Assistant message after tool results (no pending user) — + // append to the last turn's assistant text to preserve context. + last := &p.Turns[len(p.Turns)-1] + if last.AssistantText != "" { + last.AssistantText += "\n" + assistantText + } else { + last.AssistantText = assistantText + } } } } @@ -922,6 +939,27 @@ func parseOpenAIRequest(payload []byte) *parsedOpenAIRequest { return p } +// bakeToolResultsIntoTurns merges tool results into the last turn's assistant text +// when there's no active H2 session to resume. This ensures the model sees the +// full tool interaction context in a new conversation. +func bakeToolResultsIntoTurns(parsed *parsedOpenAIRequest) { + if len(parsed.ToolResults) == 0 || len(parsed.Turns) == 0 { + return + } + last := &parsed.Turns[len(parsed.Turns)-1] + var toolContext strings.Builder + for _, tr := range parsed.ToolResults { + toolContext.WriteString("\n\n[Tool Result]\n") + toolContext.WriteString(tr.Content) + } + if last.AssistantText != "" { + last.AssistantText += toolContext.String() + } else { + last.AssistantText = toolContext.String() + } + parsed.ToolResults = nil // consumed +} + func extractTextContent(content gjson.Result) string { if content.Type == gjson.String { return content.String() From c8e79c378732ad7ce4af1337c074bacaf61665cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=A7=9C=E6=81=92?= Date: Wed, 25 Mar 2026 17:19:11 +0800 Subject: [PATCH 04/13] fix(cursor): prevent session key collision across users Include client API key in session key derivation to prevent different users sharing the same proxy from accidentally resuming each other's H2 streams when they send identical first messages with the same model. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/runtime/executor/cursor_executor.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/internal/runtime/executor/cursor_executor.go b/internal/runtime/executor/cursor_executor.go index 3debf73c..699c8d21 100644 --- a/internal/runtime/executor/cursor_executor.go +++ b/internal/runtime/executor/cursor_executor.go @@ -295,7 +295,7 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A log.Debugf("cursor: parsed request: model=%s userText=%d chars, turns=%d, tools=%d, toolResults=%d", parsed.Model, len(parsed.UserText), len(parsed.Turns), len(parsed.Tools), len(parsed.ToolResults)) - sessionKey := deriveSessionKey(parsed.Model, parsed.Messages) + sessionKey := deriveSessionKey(apiKeyFromContext(ctx), parsed.Model, parsed.Messages) needsTranslate := from.String() != "" && from.String() != "openai" // Check if we can resume an existing session with tool results @@ -1089,7 +1089,7 @@ func newH2Client() *http.Client { } } -func deriveSessionKey(model string, messages []gjson.Result) string { +func deriveSessionKey(clientKey string, model string, messages []gjson.Result) string { var firstUserContent string for _, msg := range messages { if msg.Get("role").String() == "user" { @@ -1097,9 +1097,10 @@ func deriveSessionKey(model string, messages []gjson.Result) string { break } } - input := model + ":" + firstUserContent - if len(input) > 200 { - input = input[:200] + // Include client API key to prevent session collisions across users + input := clientKey + ":" + model + ":" + firstUserContent + if len(input) > 300 { + input = input[:300] } h := sha256.Sum256([]byte(input)) return hex.EncodeToString(h[:])[:16] From 274f29e26b8cbecd19fbe4cb0de248c58c635b6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=A7=9C=E6=81=92?= Date: Wed, 25 Mar 2026 17:24:37 +0800 Subject: [PATCH 05/13] fix(cursor): improve session key uniqueness for multi-session safety Include system prompt prefix (first 200 chars) in session key derivation. Claude Code sessions have unique system prompts containing cwd, session_id, file paths, etc., making collisions between concurrent sessions from the same user virtually impossible. Session key now = SHA256(apiKey + model + systemPrompt[:200] + firstUserMsg) Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/runtime/executor/cursor_executor.go | 23 +++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/internal/runtime/executor/cursor_executor.go b/internal/runtime/executor/cursor_executor.go index 699c8d21..515d1001 100644 --- a/internal/runtime/executor/cursor_executor.go +++ b/internal/runtime/executor/cursor_executor.go @@ -1091,16 +1091,27 @@ func newH2Client() *http.Client { func deriveSessionKey(clientKey string, model string, messages []gjson.Result) string { var firstUserContent string + var systemContent string for _, msg := range messages { - if msg.Get("role").String() == "user" { + role := msg.Get("role").String() + if role == "user" && firstUserContent == "" { firstUserContent = extractTextContent(msg.Get("content")) - break + } else if role == "system" && systemContent == "" { + // System prompt differs per Claude Code session (contains cwd, session_id, etc.) + content := extractTextContent(msg.Get("content")) + if len(content) > 200 { + systemContent = content[:200] + } else { + systemContent = content + } } } - // Include client API key to prevent session collisions across users - input := clientKey + ":" + model + ":" + firstUserContent - if len(input) > 300 { - input = input[:300] + // Include client API key + system prompt hash to prevent session collisions: + // - Different users have different API keys + // - Different Claude Code sessions have different system prompts (cwd, tools, etc.) + input := clientKey + ":" + model + ":" + systemContent + ":" + firstUserContent + if len(input) > 500 { + input = input[:500] } h := sha256.Sum256([]byte(input)) return hex.EncodeToString(h[:])[:16] From 9613f0b3f9d46a028653380106094e69eb3d3d3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=A7=9C=E6=81=92?= Date: Wed, 25 Mar 2026 20:29:49 +0800 Subject: [PATCH 06/13] feat(cursor): deterministic conversation_id from Claude Code session cch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the cch hash from Claude Code's billing header in the system prompt (x-anthropic-billing-header: ...cch=XXXXX;) and use it to derive a deterministic conversation_id instead of generating a random UUID. Same Claude Code session → same cch → same conversation_id → Cursor server can reuse conversation state across multiple turns, preserving tool call results and other context without re-encoding history. Also cleans up temporary debug logging from previous iterations. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/runtime/executor/cursor_executor.go | 41 ++++++++++++++++++-- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/internal/runtime/executor/cursor_executor.go b/internal/runtime/executor/cursor_executor.go index 515d1001..6fcef349 100644 --- a/internal/runtime/executor/cursor_executor.go +++ b/internal/runtime/executor/cursor_executor.go @@ -209,7 +209,9 @@ func (e *CursorExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r } parsed := parseOpenAIRequest(payload) - params := buildRunRequestParams(parsed) + cch := extractCCH(parsed.SystemPrompt) + conversationId := deriveConversationId(apiKeyFromContext(ctx), cch) + params := buildRunRequestParams(parsed, conversationId) requestBytes := cursorproto.EncodeRunRequest(params) framedRequest := cursorproto.FrameConnectMessage(requestBytes, 0) @@ -295,6 +297,10 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A log.Debugf("cursor: parsed request: model=%s userText=%d chars, turns=%d, tools=%d, toolResults=%d", parsed.Model, len(parsed.UserText), len(parsed.Turns), len(parsed.Tools), len(parsed.ToolResults)) + cch := extractCCH(parsed.SystemPrompt) + conversationId := deriveConversationId(apiKeyFromContext(ctx), cch) + log.Debugf("cursor: cch=%s conversationId=%s", cch, conversationId) + sessionKey := deriveSessionKey(apiKeyFromContext(ctx), parsed.Model, parsed.Messages) needsTranslate := from.String() != "" && from.String() != "openai" @@ -328,7 +334,7 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A bakeToolResultsIntoTurns(parsed) } - params := buildRunRequestParams(parsed) + params := buildRunRequestParams(parsed, conversationId) requestBytes := cursorproto.EncodeRunRequest(params) framedRequest := cursorproto.FrameConnectMessage(requestBytes, 0) @@ -1023,13 +1029,13 @@ func parseDataURL(url string) *cursorproto.ImageData { } } -func buildRunRequestParams(parsed *parsedOpenAIRequest) *cursorproto.RunRequestParams { +func buildRunRequestParams(parsed *parsedOpenAIRequest, conversationId string) *cursorproto.RunRequestParams { params := &cursorproto.RunRequestParams{ ModelId: parsed.Model, SystemPrompt: parsed.SystemPrompt, UserText: parsed.UserText, MessageId: uuid.New().String(), - ConversationId: uuid.New().String(), + ConversationId: conversationId, Images: parsed.Images, Turns: parsed.Turns, BlobStore: make(map[string][]byte), @@ -1089,6 +1095,33 @@ func newH2Client() *http.Client { } } +// extractCCH extracts the cch value from the system prompt's billing header. +// Format: x-anthropic-billing-header: cc_version=...; cc_entrypoint=cli; cch=XXXXX; +// The cch is unique per Claude Code session and stable across requests in the same session. +func extractCCH(systemPrompt string) string { + idx := strings.Index(systemPrompt, "cch=") + if idx < 0 { + return "" + } + rest := systemPrompt[idx+4:] + end := strings.IndexAny(rest, "; \n") + if end < 0 { + return rest + } + return rest[:end] +} + +// deriveConversationId generates a deterministic conversation_id from the client API key and cch. +// Same Claude Code session → same cch → same conversation_id → Cursor server can reuse context. +func deriveConversationId(apiKey, cch string) string { + if cch == "" { + return uuid.New().String() + } + h := sha256.Sum256([]byte("cursor-conv:" + apiKey + ":" + cch)) + s := hex.EncodeToString(h[:16]) + return fmt.Sprintf("%s-%s-%s-%s-%s", s[:8], s[8:12], s[12:16], s[16:20], s[20:32]) +} + func deriveSessionKey(clientKey string, model string, messages []gjson.Result) string { var firstUserContent string var systemContent string From c95620f90e9f0990b501ce25003055f705533d31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=A7=9C=E6=81=92?= Date: Thu, 26 Mar 2026 10:51:47 +0800 Subject: [PATCH 07/13] feat(cursor): conversation checkpoint + session_id for multi-turn context - Capture conversation_checkpoint_update from Cursor server (was ignored) - Store checkpoint per conversationId, replay as conversation_state on next request - Use protowire to embed raw checkpoint bytes directly (no deserialization) - Extract session_id from Claude Code metadata for stable conversationId across resume - Flatten conversation history into userText as fallback when no checkpoint available - Use conversationId as session key for reliable tool call resume - Add checkpoint TTL cleanup (30min) Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 1 + internal/auth/cursor/proto/decode.go | 9 +- internal/auth/cursor/proto/encode.go | 175 ++++++++++++++++- internal/runtime/executor/cursor_executor.go | 196 +++++++++++++++---- 4 files changed, 341 insertions(+), 40 deletions(-) diff --git a/.gitignore b/.gitignore index e6e6ab0a..87c9a410 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Binaries cli-proxy-api cliproxy +/server *.exe diff --git a/internal/auth/cursor/proto/decode.go b/internal/auth/cursor/proto/decode.go index 898ca932..b3753a59 100644 --- a/internal/auth/cursor/proto/decode.go +++ b/internal/auth/cursor/proto/decode.go @@ -35,6 +35,7 @@ const ( ServerMsgTurnEnded // Turn has ended (no more output) ServerMsgHeartbeat // Server heartbeat ServerMsgTokenDelta // Token usage delta + ServerMsgCheckpoint // Conversation checkpoint update ) // DecodedServerMessage holds parsed data from an AgentServerMessage. @@ -69,6 +70,9 @@ type DecodedServerMessage struct { // For TokenDeltaUpdate TokenDelta int64 + + // For conversation checkpoint update (raw bytes, not decoded) + CheckpointData []byte } // DecodeAgentServerMessage parses an AgentServerMessage and returns @@ -104,8 +108,9 @@ func DecodeAgentServerMessage(data []byte) (*DecodedServerMessage, error) { case ASM_KvServerMessage: decodeKvServerMessage(val, msg) case ASM_ConversationCheckpoint: - // Ignore checkpoint updates - log.Debugf("DecodeAgentServerMessage: ignoring ConversationCheckpoint") + msg.Type = ServerMsgCheckpoint + msg.CheckpointData = append([]byte(nil), val...) // copy raw bytes + log.Debugf("DecodeAgentServerMessage: captured checkpoint %d bytes", len(val)) } case protowire.VarintType: diff --git a/internal/auth/cursor/proto/encode.go b/internal/auth/cursor/proto/encode.go index 8ee2910f..b1be6551 100644 --- a/internal/auth/cursor/proto/encode.go +++ b/internal/auth/cursor/proto/encode.go @@ -10,6 +10,7 @@ import ( "fmt" log "github.com/sirupsen/logrus" + "google.golang.org/protobuf/encoding/protowire" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/reflect/protoreflect" "google.golang.org/protobuf/types/dynamicpb" @@ -29,6 +30,7 @@ type RunRequestParams struct { Turns []TurnData McpTools []McpToolDef BlobStore map[string][]byte // hex(sha256) -> data, populated during encoding + RawCheckpoint []byte // if non-nil, use as conversation_state directly (from server checkpoint) } type ImageData struct { @@ -102,7 +104,13 @@ func EncodeHeartbeat() []byte { // EncodeRunRequest builds a full AgentClientMessage wrapping an AgentRunRequest. // Mirrors buildCursorRequest() in cursor-fetch.ts. +// If p.RawCheckpoint is set, it is used directly as the conversation_state bytes +// (from a previous conversation_checkpoint_update), skipping manual turn construction. func EncodeRunRequest(p *RunRequestParams) []byte { + if p.RawCheckpoint != nil { + return encodeRunRequestWithCheckpoint(p) + } + if p.BlobStore == nil { p.BlobStore = make(map[string][]byte) } @@ -153,12 +161,19 @@ func EncodeRunRequest(p *RunRequestParams) []byte { rootField := field(css, "root_prompt_messages_json") rootList := css.Mutable(rootField).List() rootList.Append(protoreflect.ValueOfBytes(blobId)) - // turns: repeated bytes + // turns: repeated bytes (field 8) + turns_old (field 2) for compatibility turnsField := field(css, "turns") turnsList := css.Mutable(turnsField).List() for _, tb := range turnBytes { turnsList.Append(protoreflect.ValueOfBytes(tb)) } + turnsOldField := field(css, "turns_old") + if turnsOldField != nil { + turnsOldList := css.Mutable(turnsOldField).List() + for _, tb := range turnBytes { + turnsOldList.Append(protoreflect.ValueOfBytes(tb)) + } + } // --- UserMessage (current) --- userMessage := newMsg("UserMessage") @@ -227,6 +242,164 @@ func EncodeRunRequest(p *RunRequestParams) []byte { return marshal(acm) } +// encodeRunRequestWithCheckpoint builds an AgentClientMessage using a raw checkpoint +// as conversation_state. The checkpoint bytes are embedded directly without deserialization. +func encodeRunRequestWithCheckpoint(p *RunRequestParams) []byte { + // Build UserMessage + userMessage := newMsg("UserMessage") + setStr(userMessage, "text", p.UserText) + setStr(userMessage, "message_id", p.MessageId) + if len(p.Images) > 0 { + sc := newMsg("SelectedContext") + imgsField := field(sc, "selected_images") + imgsList := sc.Mutable(imgsField).List() + for _, img := range p.Images { + si := newMsg("SelectedImage") + setStr(si, "uuid", generateId()) + setStr(si, "mime_type", img.MimeType) + setBytes(si, "data", img.Data) + imgsList.Append(protoreflect.ValueOfMessage(si.ProtoReflect())) + } + setMsg(userMessage, "selected_context", sc) + } + + // Build ConversationAction with UserMessageAction + uma := newMsg("UserMessageAction") + setMsg(uma, "user_message", userMessage) + ca := newMsg("ConversationAction") + setMsg(ca, "user_message_action", uma) + caBytes := marshal(ca) + + // Build ModelDetails + md := newMsg("ModelDetails") + setStr(md, "model_id", p.ModelId) + setStr(md, "display_model_id", p.ModelId) + setStr(md, "display_name", p.ModelId) + mdBytes := marshal(md) + + // Build McpTools + var mcpToolsBytes []byte + if len(p.McpTools) > 0 { + mcpTools := newMsg("McpTools") + toolsField := field(mcpTools, "mcp_tools") + toolsList := mcpTools.Mutable(toolsField).List() + for _, tool := range p.McpTools { + td := newMsg("McpToolDefinition") + setStr(td, "name", tool.Name) + setStr(td, "description", tool.Description) + if len(tool.InputSchema) > 0 { + setBytes(td, "input_schema", jsonToProtobufValueBytes(tool.InputSchema)) + } + setStr(td, "provider_identifier", "proxy") + setStr(td, "tool_name", tool.Name) + toolsList.Append(protoreflect.ValueOfMessage(td.ProtoReflect())) + } + mcpToolsBytes = marshal(mcpTools) + } + + // Manually assemble AgentRunRequest using protowire to embed raw checkpoint + var arrBuf []byte + // field 1: conversation_state = raw checkpoint bytes (length-delimited) + arrBuf = protowire.AppendTag(arrBuf, ARR_ConversationState, protowire.BytesType) + arrBuf = protowire.AppendBytes(arrBuf, p.RawCheckpoint) + // field 2: action = ConversationAction + arrBuf = protowire.AppendTag(arrBuf, ARR_Action, protowire.BytesType) + arrBuf = protowire.AppendBytes(arrBuf, caBytes) + // field 3: model_details = ModelDetails + arrBuf = protowire.AppendTag(arrBuf, ARR_ModelDetails, protowire.BytesType) + arrBuf = protowire.AppendBytes(arrBuf, mdBytes) + // field 4: mcp_tools = McpTools + if len(mcpToolsBytes) > 0 { + arrBuf = protowire.AppendTag(arrBuf, ARR_McpTools, protowire.BytesType) + arrBuf = protowire.AppendBytes(arrBuf, mcpToolsBytes) + } + // field 5: conversation_id = string + if p.ConversationId != "" { + arrBuf = protowire.AppendTag(arrBuf, ARR_ConversationId, protowire.BytesType) + arrBuf = protowire.AppendString(arrBuf, p.ConversationId) + } + + // Wrap in AgentClientMessage field 1 (run_request) + var acmBuf []byte + acmBuf = protowire.AppendTag(acmBuf, ACM_RunRequest, protowire.BytesType) + acmBuf = protowire.AppendBytes(acmBuf, arrBuf) + + log.Debugf("cursor encode: built RunRequest with checkpoint (%d bytes), total=%d bytes", len(p.RawCheckpoint), len(acmBuf)) + return acmBuf +} + +// ResumeRequestParams holds data for a ResumeAction request. +type ResumeRequestParams struct { + ModelId string + ConversationId string + McpTools []McpToolDef +} + +// EncodeResumeRequest builds an AgentClientMessage with ResumeAction. +// Used to resume a conversation by conversation_id without re-sending full history. +func EncodeResumeRequest(p *ResumeRequestParams) []byte { + // RequestContext with tools + rc := newMsg("RequestContext") + if len(p.McpTools) > 0 { + toolsField := field(rc, "tools") + toolsList := rc.Mutable(toolsField).List() + for _, tool := range p.McpTools { + td := newMsg("McpToolDefinition") + setStr(td, "name", tool.Name) + setStr(td, "description", tool.Description) + if len(tool.InputSchema) > 0 { + setBytes(td, "input_schema", jsonToProtobufValueBytes(tool.InputSchema)) + } + setStr(td, "provider_identifier", "proxy") + setStr(td, "tool_name", tool.Name) + toolsList.Append(protoreflect.ValueOfMessage(td.ProtoReflect())) + } + } + + // ResumeAction + ra := newMsg("ResumeAction") + setMsg(ra, "request_context", rc) + + // ConversationAction with resume_action + ca := newMsg("ConversationAction") + setMsg(ca, "resume_action", ra) + + // ModelDetails + md := newMsg("ModelDetails") + setStr(md, "model_id", p.ModelId) + setStr(md, "display_model_id", p.ModelId) + setStr(md, "display_name", p.ModelId) + + // AgentRunRequest — no conversation_state needed for resume + arr := newMsg("AgentRunRequest") + setMsg(arr, "action", ca) + setMsg(arr, "model_details", md) + setStr(arr, "conversation_id", p.ConversationId) + + // McpTools at top level + if len(p.McpTools) > 0 { + mcpTools := newMsg("McpTools") + toolsField := field(mcpTools, "mcp_tools") + toolsList := mcpTools.Mutable(toolsField).List() + for _, tool := range p.McpTools { + td := newMsg("McpToolDefinition") + setStr(td, "name", tool.Name) + setStr(td, "description", tool.Description) + if len(tool.InputSchema) > 0 { + setBytes(td, "input_schema", jsonToProtobufValueBytes(tool.InputSchema)) + } + setStr(td, "provider_identifier", "proxy") + setStr(td, "tool_name", tool.Name) + toolsList.Append(protoreflect.ValueOfMessage(td.ProtoReflect())) + } + setMsg(arr, "mcp_tools", mcpTools) + } + + acm := newMsg("AgentClientMessage") + setMsg(acm, "run_request", arr) + return marshal(acm) +} + // --- KV response encoders --- // Mirrors handleKvMessage() in cursor-fetch.ts diff --git a/internal/runtime/executor/cursor_executor.go b/internal/runtime/executor/cursor_executor.go index 6fcef349..e92ce6fa 100644 --- a/internal/runtime/executor/cursor_executor.go +++ b/internal/runtime/executor/cursor_executor.go @@ -35,14 +35,23 @@ const ( cursorClientVersion = "cli-2026.02.13-41ac335" cursorAuthType = "cursor" cursorHeartbeatInterval = 5 * time.Second - cursorSessionTTL = 5 * time.Minute + cursorSessionTTL = 5 * time.Minute + cursorCheckpointTTL = 30 * time.Minute ) // CursorExecutor handles requests to the Cursor API via Connect+Protobuf protocol. type CursorExecutor struct { - cfg *config.Config - mu sync.Mutex - sessions map[string]*cursorSession + cfg *config.Config + mu sync.Mutex + sessions map[string]*cursorSession + checkpoints map[string]*savedCheckpoint // keyed by conversationId +} + +// savedCheckpoint stores the server's conversation_checkpoint_update for reuse. +type savedCheckpoint struct { + data []byte // raw ConversationStateStructure protobuf bytes + blobStore map[string][]byte // blobs referenced by the checkpoint + updatedAt time.Time } type cursorSession struct { @@ -68,8 +77,9 @@ type pendingMcpExec struct { // NewCursorExecutor constructs a new executor instance. func NewCursorExecutor(cfg *config.Config) *CursorExecutor { e := &CursorExecutor{ - cfg: cfg, - sessions: make(map[string]*cursorSession), + cfg: cfg, + sessions: make(map[string]*cursorSession), + checkpoints: make(map[string]*savedCheckpoint), } go e.cleanupLoop() return e @@ -106,6 +116,11 @@ func (e *CursorExecutor) cleanupLoop() { delete(e.sessions, k) } } + for k, cp := range e.checkpoints { + if time.Since(cp.updatedAt) > cursorCheckpointTTL { + delete(e.checkpoints, k) + } + } e.mu.Unlock() } } @@ -209,8 +224,8 @@ func (e *CursorExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r } parsed := parseOpenAIRequest(payload) - cch := extractCCH(parsed.SystemPrompt) - conversationId := deriveConversationId(apiKeyFromContext(ctx), cch) + ccSessId := extractClaudeCodeSessionId(req.Payload) + conversationId := deriveConversationId(apiKeyFromContext(ctx), ccSessId, parsed.SystemPrompt) params := buildRunRequestParams(parsed, conversationId) requestBytes := cursorproto.EncodeRunRequest(params) @@ -241,6 +256,7 @@ func (e *CursorExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r nil, nil, nil, // tokenUsage - non-streaming + nil, // onCheckpoint - non-streaming doesn't persist ) id := "chatcmpl-" + uuid.New().String()[:28] @@ -279,6 +295,12 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A return nil, fmt.Errorf("cursor: access token not found") } + // Extract session_id from metadata BEFORE translation (translation strips metadata) + ccSessionId := extractClaudeCodeSessionId(req.Payload) + if ccSessionId == "" && len(opts.OriginalRequest) > 0 { + ccSessionId = extractClaudeCodeSessionId(opts.OriginalRequest) + } + // Translate input to OpenAI format if needed from := opts.SourceFormat to := sdktranslator.FromString("openai") @@ -297,11 +319,11 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A log.Debugf("cursor: parsed request: model=%s userText=%d chars, turns=%d, tools=%d, toolResults=%d", parsed.Model, len(parsed.UserText), len(parsed.Turns), len(parsed.Tools), len(parsed.ToolResults)) - cch := extractCCH(parsed.SystemPrompt) - conversationId := deriveConversationId(apiKeyFromContext(ctx), cch) - log.Debugf("cursor: cch=%s conversationId=%s", cch, conversationId) + conversationId := deriveConversationId(apiKeyFromContext(ctx), ccSessionId, parsed.SystemPrompt) + log.Debugf("cursor: conversationId=%s ccSessionId=%s", conversationId, ccSessionId) - sessionKey := deriveSessionKey(apiKeyFromContext(ctx), parsed.Model, parsed.Messages) + // Use conversationId as session key — stable across requests in the same Claude Code session + sessionKey := conversationId needsTranslate := from.String() != "" && from.String() != "openai" // Check if we can resume an existing session with tool results @@ -327,14 +349,33 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A } e.mu.Unlock() - // If tool results exist but no session to resume, bake them into turns - // so the model sees tool interaction context in the new conversation. - if len(parsed.ToolResults) > 0 { - log.Debugf("cursor: no session to resume, baking %d tool results into turns", len(parsed.ToolResults)) - bakeToolResultsIntoTurns(parsed) - } + // Look up saved checkpoint for this conversation + e.mu.Lock() + saved, hasCheckpoint := e.checkpoints[conversationId] + e.mu.Unlock() params := buildRunRequestParams(parsed, conversationId) + + if hasCheckpoint && saved.data != nil { + log.Debugf("cursor: using saved checkpoint (%d bytes) for conversationId=%s", len(saved.data), conversationId) + params.RawCheckpoint = saved.data + // Merge saved blobStore into params + if params.BlobStore == nil { + params.BlobStore = make(map[string][]byte) + } + for k, v := range saved.blobStore { + if _, exists := params.BlobStore[k]; !exists { + params.BlobStore[k] = v + } + } + } else if len(parsed.ToolResults) > 0 || len(parsed.Turns) > 0 { + // Fallback: no checkpoint available (cold resume / proxy restart). + // Flatten the full conversation history (including tool interactions) into userText. + // Cursor's turns encoding is not reliably read by the model, but userText always works. + log.Debugf("cursor: no checkpoint, flattening %d turns + %d tool results into userText", len(parsed.Turns), len(parsed.ToolResults)) + flattenConversationIntoUserText(parsed) + params = buildRunRequestParams(parsed, conversationId) + } requestBytes := cursorproto.EncodeRunRequest(params) framedRequest := cursorproto.FrameConnectMessage(requestBytes, 0) @@ -488,6 +529,17 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A }, toolResultCh, usage, + func(cpData []byte) { + // Save checkpoint for this conversation + e.mu.Lock() + e.checkpoints[conversationId] = &savedCheckpoint{ + data: cpData, + blobStore: params.BlobStore, + updatedAt: time.Now(), + } + e.mu.Unlock() + log.Debugf("cursor: saved checkpoint (%d bytes) for conversationId=%s", len(cpData), conversationId) + }, ) // processH2SessionFrames returned — stream is done @@ -641,6 +693,7 @@ func processH2SessionFrames( onMcpExec func(exec pendingMcpExec), toolResultCh <-chan []toolResultInfo, // nil for no tool result injection; non-nil to wait for results tokenUsage *cursorTokenUsage, // tracks accumulated token usage (may be nil) + onCheckpoint func(data []byte), // called when server sends conversation_checkpoint_update ) { var buf bytes.Buffer rejectReason := "Tool not available in this environment. Use the MCP tools provided instead." @@ -711,6 +764,12 @@ func processH2SessionFrames( // Server heartbeat, ignore silently continue + case cursorproto.ServerMsgCheckpoint: + if onCheckpoint != nil && len(msg.CheckpointData) > 0 { + onCheckpoint(msg.CheckpointData) + } + continue + case cursorproto.ServerMsgTokenDelta: if tokenUsage != nil && msg.TokenDelta > 0 { tokenUsage.addOutput(msg.TokenDelta) @@ -802,6 +861,10 @@ func processH2SessionFrames( stream.Write(cursorproto.FrameConnectMessage(cursorproto.EncodeKvSetBlobResult(wmsg.KvId), 0)) case cursorproto.ServerMsgExecRequestCtx: stream.Write(cursorproto.FrameConnectMessage(cursorproto.EncodeExecRequestContextResult(wmsg.ExecMsgId, wmsg.ExecId, mcpTools), 0)) + case cursorproto.ServerMsgCheckpoint: + if onCheckpoint != nil && len(wmsg.CheckpointData) > 0 { + onCheckpoint(wmsg.CheckpointData) + } } } case <-stream.Done(): @@ -948,22 +1011,56 @@ func parseOpenAIRequest(payload []byte) *parsedOpenAIRequest { // bakeToolResultsIntoTurns merges tool results into the last turn's assistant text // when there's no active H2 session to resume. This ensures the model sees the // full tool interaction context in a new conversation. -func bakeToolResultsIntoTurns(parsed *parsedOpenAIRequest) { - if len(parsed.ToolResults) == 0 || len(parsed.Turns) == 0 { - return +// flattenConversationIntoUserText flattens the full conversation history +// (turns + tool results) into the UserText field as plain text. +// This is the fallback for cold resume when no checkpoint is available. +// Cursor reliably reads UserText but ignores structured turns. +func flattenConversationIntoUserText(parsed *parsedOpenAIRequest) { + var buf strings.Builder + + // Flatten turns into readable context + for _, turn := range parsed.Turns { + if turn.UserText != "" { + buf.WriteString("USER: ") + buf.WriteString(turn.UserText) + buf.WriteString("\n\n") + } + if turn.AssistantText != "" { + buf.WriteString("ASSISTANT: ") + buf.WriteString(turn.AssistantText) + buf.WriteString("\n\n") + } } - last := &parsed.Turns[len(parsed.Turns)-1] - var toolContext strings.Builder + + // Flatten tool results for _, tr := range parsed.ToolResults { - toolContext.WriteString("\n\n[Tool Result]\n") - toolContext.WriteString(tr.Content) + buf.WriteString("TOOL_RESULT (call_id: ") + buf.WriteString(tr.ToolCallId) + buf.WriteString("): ") + // Truncate very large tool results to avoid overwhelming the context + content := tr.Content + if len(content) > 8000 { + content = content[:8000] + "\n... [truncated]" + } + buf.WriteString(content) + buf.WriteString("\n\n") } - if last.AssistantText != "" { - last.AssistantText += toolContext.String() + + if buf.Len() > 0 { + buf.WriteString("The above is the previous conversation context including tool call results.\n") + buf.WriteString("Continue your response based on this context.\n\n") + } + + // Prepend flattened history to the current UserText + if parsed.UserText != "" { + parsed.UserText = buf.String() + "Current request: " + parsed.UserText } else { - last.AssistantText = toolContext.String() + parsed.UserText = buf.String() + "Continue from the conversation above." } - parsed.ToolResults = nil // consumed + + // Clear turns and tool results since they're now in UserText + parsed.Turns = nil + parsed.ToolResults = nil } func extractTextContent(content gjson.Result) string { @@ -1096,8 +1193,6 @@ func newH2Client() *http.Client { } // extractCCH extracts the cch value from the system prompt's billing header. -// Format: x-anthropic-billing-header: cc_version=...; cc_entrypoint=cli; cch=XXXXX; -// The cch is unique per Claude Code session and stable across requests in the same session. func extractCCH(systemPrompt string) string { idx := strings.Index(systemPrompt, "cch=") if idx < 0 { @@ -1111,13 +1206,40 @@ func extractCCH(systemPrompt string) string { return rest[:end] } -// deriveConversationId generates a deterministic conversation_id from the client API key and cch. -// Same Claude Code session → same cch → same conversation_id → Cursor server can reuse context. -func deriveConversationId(apiKey, cch string) string { - if cch == "" { - return uuid.New().String() +// extractClaudeCodeSessionId extracts session_id from Claude Code's metadata.user_id JSON. +// Format: {"metadata":{"user_id":"{\"session_id\":\"xxx\",\"device_id\":\"yyy\"}"}} +func extractClaudeCodeSessionId(payload []byte) string { + userIdStr := gjson.GetBytes(payload, "metadata.user_id").String() + if userIdStr == "" { + return "" } - h := sha256.Sum256([]byte("cursor-conv:" + apiKey + ":" + cch)) + // user_id is a JSON string that needs to be parsed again + sid := gjson.Get(userIdStr, "session_id").String() + return sid +} + +// deriveConversationId generates a deterministic conversation_id. +// Priority: session_id (stable across resume) > system prompt hash (fallback). +func deriveConversationId(apiKey, sessionId, systemPrompt string) string { + var input string + if sessionId != "" { + // Best: use Claude Code's session_id — stable even across resume + input = "cursor-conv:" + apiKey + ":" + sessionId + } else { + // Fallback: use system prompt content minus volatile cch + stable := systemPrompt + if idx := strings.Index(stable, "cch="); idx >= 0 { + end := strings.IndexAny(stable[idx:], "; \n") + if end > 0 { + stable = stable[:idx] + stable[idx+end:] + } + } + if len(stable) > 500 { + stable = stable[:500] + } + input = "cursor-conv:" + apiKey + ":" + stable + } + h := sha256.Sum256([]byte(input)) s := hex.EncodeToString(h[:16]) return fmt.Sprintf("%s-%s-%s-%s-%s", s[:8], s[8:12], s[12:16], s[16:20], s[20:32]) } From dcfbec2990d3751a14517bf20b9fe554494f7c3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=A7=9C=E6=81=92?= Date: Thu, 26 Mar 2026 11:10:07 +0800 Subject: [PATCH 08/13] feat(cursor): add management API for Cursor OAuth authentication - Add RequestCursorToken handler with PKCE + polling flow - Register /v0/management/cursor-auth-url route - Returns login URL + state for browser auth, polls in background - Saves cursor.json with access/refresh tokens on success Co-Authored-By: Claude Opus 4.6 (1M context) --- .../api/handlers/management/auth_files.go | 72 +++++++++++++++++++ internal/api/server.go | 1 + 2 files changed, 73 insertions(+) diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index a0d9c159..29932669 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -29,6 +29,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude" "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex" "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/copilot" + cursorauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/cursor" geminiAuth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini" gitlabauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gitlab" iflowauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/iflow" @@ -3694,3 +3695,74 @@ func (h *Handler) RequestKiloToken(c *gin.Context) { "verification_uri": resp.VerificationURL, }) } + +// RequestCursorToken initiates the Cursor PKCE authentication flow. +// The user opens the returned URL in a browser, logs in, and the server polls +// until the authentication completes. +func (h *Handler) RequestCursorToken(c *gin.Context) { + ctx := context.Background() + ctx = PopulateAuthContext(ctx, c) + + fmt.Println("Initializing Cursor authentication...") + + authParams, err := cursorauth.GenerateAuthParams() + if err != nil { + log.Errorf("Failed to generate Cursor auth params: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate auth params"}) + return + } + + state := fmt.Sprintf("cur-%d", time.Now().UnixNano()) + RegisterOAuthSession(state, "cursor") + + go func() { + fmt.Println("Waiting for Cursor authentication...") + fmt.Printf("Open this URL in your browser: %s\n", authParams.LoginURL) + + tokens, errPoll := cursorauth.PollForAuth(ctx, authParams.UUID, authParams.Verifier) + if errPoll != nil { + SetOAuthSessionError(state, "Authentication failed: "+errPoll.Error()) + fmt.Printf("Cursor authentication failed: %v\n", errPoll) + return + } + + // Build metadata + metadata := map[string]any{ + "type": "cursor", + "access_token": tokens.AccessToken, + "refresh_token": tokens.RefreshToken, + "timestamp": time.Now().UnixMilli(), + } + + // Extract expiry from JWT + expiry := cursorauth.GetTokenExpiry(tokens.AccessToken) + if !expiry.IsZero() { + metadata["expires_at"] = expiry.Format(time.RFC3339) + } + + fileName := "cursor.json" + record := &coreauth.Auth{ + ID: fileName, + Provider: "cursor", + FileName: fileName, + Label: "Cursor User", + Metadata: metadata, + } + savedPath, errSave := h.saveTokenRecord(ctx, record) + if errSave != nil { + log.Errorf("Failed to save Cursor tokens: %v", errSave) + SetOAuthSessionError(state, "Failed to save tokens") + return + } + + fmt.Printf("Cursor authentication successful! Token saved to %s\n", savedPath) + CompleteOAuthSession(state) + CompleteOAuthSessionsByProvider("cursor") + }() + + c.JSON(200, gin.H{ + "status": "ok", + "url": authParams.LoginURL, + "state": state, + }) +} diff --git a/internal/api/server.go b/internal/api/server.go index 2a63c97c..95327eef 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -682,6 +682,7 @@ func (s *Server) registerManagementRoutes() { mgmt.GET("/iflow-auth-url", s.mgmt.RequestIFlowToken) mgmt.POST("/iflow-auth-url", s.mgmt.RequestIFlowCookieToken) mgmt.GET("/kiro-auth-url", s.mgmt.RequestKiroToken) + mgmt.GET("/cursor-auth-url", s.mgmt.RequestCursorToken) mgmt.GET("/github-auth-url", s.mgmt.RequestGitHubToken) mgmt.POST("/oauth-callback", s.mgmt.PostOAuthCallback) mgmt.GET("/get-auth-status", s.mgmt.GetAuthStatus) From de5fe714784a41d730fbc2e700fb8ad06bfee3b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=A7=9C=E6=81=92?= Date: Thu, 26 Mar 2026 11:27:49 +0800 Subject: [PATCH 09/13] feat(cursor): multi-account routing with round-robin and session isolation - Add cursor/filename.go for multi-account credential file naming - Include auth.ID in session and checkpoint keys for per-account isolation - Record authID in cursorSession, validate on resume to prevent cross-account access - Management API /cursor-auth-url supports ?label= for creating named accounts - Leverages existing conductor round-robin + failover framework Co-Authored-By: Claude Opus 4.6 (1M context) --- .../api/handlers/management/auth_files.go | 12 ++++++--- internal/auth/cursor/filename.go | 16 ++++++++++++ internal/runtime/executor/cursor_executor.go | 25 ++++++++++++------- 3 files changed, 41 insertions(+), 12 deletions(-) create mode 100644 internal/auth/cursor/filename.go diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index 29932669..df5456b9 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -3697,13 +3697,15 @@ func (h *Handler) RequestKiloToken(c *gin.Context) { } // RequestCursorToken initiates the Cursor PKCE authentication flow. +// Supports multiple accounts via ?label=xxx query parameter. // The user opens the returned URL in a browser, logs in, and the server polls // until the authentication completes. func (h *Handler) RequestCursorToken(c *gin.Context) { ctx := context.Background() ctx = PopulateAuthContext(ctx, c) - fmt.Println("Initializing Cursor authentication...") + label := strings.TrimSpace(c.Query("label")) + fmt.Printf("Initializing Cursor authentication (label=%q)...\n", label) authParams, err := cursorauth.GenerateAuthParams() if err != nil { @@ -3740,12 +3742,16 @@ func (h *Handler) RequestCursorToken(c *gin.Context) { metadata["expires_at"] = expiry.Format(time.RFC3339) } - fileName := "cursor.json" + fileName := cursorauth.CredentialFileName(label) + displayLabel := "Cursor User" + if label != "" { + displayLabel = "Cursor " + label + } record := &coreauth.Auth{ ID: fileName, Provider: "cursor", FileName: fileName, - Label: "Cursor User", + Label: displayLabel, Metadata: metadata, } savedPath, errSave := h.saveTokenRecord(ctx, record) diff --git a/internal/auth/cursor/filename.go b/internal/auth/cursor/filename.go new file mode 100644 index 00000000..47cce08b --- /dev/null +++ b/internal/auth/cursor/filename.go @@ -0,0 +1,16 @@ +package cursor + +import ( + "fmt" + "strings" +) + +// CredentialFileName returns the filename used to persist Cursor credentials. +// It uses the label as a suffix to disambiguate multiple accounts. +func CredentialFileName(label string) string { + label = strings.TrimSpace(label) + if label == "" { + return "cursor.json" + } + return fmt.Sprintf("cursor-%s.json", label) +} diff --git a/internal/runtime/executor/cursor_executor.go b/internal/runtime/executor/cursor_executor.go index e92ce6fa..67987e7f 100644 --- a/internal/runtime/executor/cursor_executor.go +++ b/internal/runtime/executor/cursor_executor.go @@ -61,6 +61,7 @@ type cursorSession struct { pending []pendingMcpExec cancel context.CancelFunc // cancels the session-scoped heartbeat (NOT tied to HTTP request) createdAt time.Time + authID string // auth file ID that created this session (for multi-account isolation) toolResultCh chan []toolResultInfo // receives tool results from the next HTTP request resumeOutCh chan cliproxyexecutor.StreamChunk // output channel for resumed response switchOutput func(ch chan cliproxyexecutor.StreamChunk) // callback to switch output channel @@ -320,10 +321,12 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A parsed.Model, len(parsed.UserText), len(parsed.Turns), len(parsed.Tools), len(parsed.ToolResults)) conversationId := deriveConversationId(apiKeyFromContext(ctx), ccSessionId, parsed.SystemPrompt) - log.Debugf("cursor: conversationId=%s ccSessionId=%s", conversationId, ccSessionId) + authID := auth.ID // e.g. "cursor.json" or "cursor-account2.json" + log.Debugf("cursor: conversationId=%s authID=%s", conversationId, authID) - // Use conversationId as session key — stable across requests in the same Claude Code session - sessionKey := conversationId + // Include authID in keys for multi-account isolation + sessionKey := authID + ":" + conversationId + checkpointKey := sessionKey // same isolation needsTranslate := from.String() != "" && from.String() != "openai" // Check if we can resume an existing session with tool results @@ -335,10 +338,13 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A } e.mu.Unlock() - if hasSession && session.stream != nil { + if hasSession && session.stream != nil && session.authID == authID { log.Debugf("cursor: resuming session %s with %d tool results", sessionKey, len(parsed.ToolResults)) return e.resumeWithToolResults(ctx, session, parsed, from, to, req, originalPayload, payload, needsTranslate) } + if hasSession && session.authID != authID { + log.Warnf("cursor: session %s belongs to auth %s, but request is from %s — skipping resume", sessionKey, session.authID, authID) + } } // Clean up any stale session for this key @@ -349,15 +355,15 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A } e.mu.Unlock() - // Look up saved checkpoint for this conversation + // Look up saved checkpoint for this conversation + account e.mu.Lock() - saved, hasCheckpoint := e.checkpoints[conversationId] + saved, hasCheckpoint := e.checkpoints[checkpointKey] e.mu.Unlock() params := buildRunRequestParams(parsed, conversationId) if hasCheckpoint && saved.data != nil { - log.Debugf("cursor: using saved checkpoint (%d bytes) for conversationId=%s", len(saved.data), conversationId) + log.Debugf("cursor: using saved checkpoint (%d bytes) for key=%s", len(saved.data), checkpointKey) params.RawCheckpoint = saved.data // Merge saved blobStore into params if params.BlobStore == nil { @@ -507,6 +513,7 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A pending: []pendingMcpExec{exec}, cancel: sessionCancel, createdAt: time.Now(), + authID: authID, toolResultCh: toolResultCh, // reuse same channel across rounds resumeOutCh: resumeOut, switchOutput: func(ch chan cliproxyexecutor.StreamChunk) { @@ -532,13 +539,13 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A func(cpData []byte) { // Save checkpoint for this conversation e.mu.Lock() - e.checkpoints[conversationId] = &savedCheckpoint{ + e.checkpoints[checkpointKey] = &savedCheckpoint{ data: cpData, blobStore: params.BlobStore, updatedAt: time.Now(), } e.mu.Unlock() - log.Debugf("cursor: saved checkpoint (%d bytes) for conversationId=%s", len(cpData), conversationId) + log.Debugf("cursor: saved checkpoint (%d bytes) for key=%s", len(cpData), checkpointKey) }, ) From 8902e1cccb5592ac7b5eb64ad542c3e602c99a16 Mon Sep 17 00:00:00 2001 From: MrHuangJser Date: Thu, 26 Mar 2026 17:03:32 +0800 Subject: [PATCH 10/13] style(cursor): replace fmt.Print* with log package for consistent logging Address Gemini Code Assist review feedback: use logrus log package instead of fmt.Printf/Println in Cursor auth handlers and CLI for unified log formatting and level control. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/api/handlers/management/auth_files.go | 10 +++++----- internal/cmd/cursor_login.go | 7 +++---- sdk/auth/cursor.go | 8 ++++---- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index df5456b9..91e1c425 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -3705,7 +3705,7 @@ func (h *Handler) RequestCursorToken(c *gin.Context) { ctx = PopulateAuthContext(ctx, c) label := strings.TrimSpace(c.Query("label")) - fmt.Printf("Initializing Cursor authentication (label=%q)...\n", label) + log.Infof("Initializing Cursor authentication (label=%q)...", label) authParams, err := cursorauth.GenerateAuthParams() if err != nil { @@ -3718,13 +3718,13 @@ func (h *Handler) RequestCursorToken(c *gin.Context) { RegisterOAuthSession(state, "cursor") go func() { - fmt.Println("Waiting for Cursor authentication...") - fmt.Printf("Open this URL in your browser: %s\n", authParams.LoginURL) + log.Info("Waiting for Cursor authentication...") + log.Infof("Open this URL in your browser: %s", authParams.LoginURL) tokens, errPoll := cursorauth.PollForAuth(ctx, authParams.UUID, authParams.Verifier) if errPoll != nil { SetOAuthSessionError(state, "Authentication failed: "+errPoll.Error()) - fmt.Printf("Cursor authentication failed: %v\n", errPoll) + log.Errorf("Cursor authentication failed: %v", errPoll) return } @@ -3761,7 +3761,7 @@ func (h *Handler) RequestCursorToken(c *gin.Context) { return } - fmt.Printf("Cursor authentication successful! Token saved to %s\n", savedPath) + log.Infof("Cursor authentication successful! Token saved to %s", savedPath) CompleteOAuthSession(state) CompleteOAuthSessionsByProvider("cursor") }() diff --git a/internal/cmd/cursor_login.go b/internal/cmd/cursor_login.go index 0ffdef1b..edebfec0 100644 --- a/internal/cmd/cursor_login.go +++ b/internal/cmd/cursor_login.go @@ -2,7 +2,6 @@ package cmd import ( "context" - "fmt" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" @@ -29,10 +28,10 @@ func DoCursorLogin(cfg *config.Config, options *LoginOptions) { } if savedPath != "" { - fmt.Printf("Authentication saved to %s\n", savedPath) + log.Infof("Authentication saved to %s", savedPath) } if record != nil && record.Label != "" { - fmt.Printf("Authenticated as %s\n", record.Label) + log.Infof("Authenticated as %s", record.Label) } - fmt.Println("Cursor authentication successful!") + log.Info("Cursor authentication successful!") } diff --git a/sdk/auth/cursor.go b/sdk/auth/cursor.go index 86cad880..d6077be6 100644 --- a/sdk/auth/cursor.go +++ b/sdk/auth/cursor.go @@ -47,8 +47,8 @@ func (a CursorAuthenticator) Login(ctx context.Context, cfg *config.Config, opts } // Display the login URL - fmt.Println("Starting Cursor authentication...") - fmt.Printf("\nPlease visit this URL to log in:\n%s\n\n", authParams.LoginURL) + log.Info("Starting Cursor authentication...") + log.Infof("Please visit this URL to log in: %s", authParams.LoginURL) // Try to open the browser automatically if !opts.NoBrowser { @@ -59,7 +59,7 @@ func (a CursorAuthenticator) Login(ctx context.Context, cfg *config.Config, opts } } - fmt.Println("Waiting for Cursor authorization...") + log.Info("Waiting for Cursor authorization...") // Poll for the auth result tokens, err := cursorauth.PollForAuth(ctx, authParams.UUID, authParams.Verifier) @@ -69,7 +69,7 @@ func (a CursorAuthenticator) Login(ctx context.Context, cfg *config.Config, opts expiresAt := cursorauth.GetTokenExpiry(tokens.AccessToken) - fmt.Println("\nCursor authentication successful!") + log.Info("Cursor authentication successful!") metadata := map[string]any{ "type": "cursor", From 40dee4453ad4c2a1f7a60752a880312b5bc8b96c Mon Sep 17 00:00:00 2001 From: MrHuangJser Date: Fri, 27 Mar 2026 10:50:32 +0800 Subject: [PATCH 11/13] feat(cursor): auto-migrate sessions to healthy account on quota exhaustion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a Cursor account's quota is exhausted, sessions bound to it can now seamlessly continue on a different account: Layer 1 — Checkpoint decoupling: Key checkpoints by conversationId (not authID:conversationId). Store authID inside savedCheckpoint. On lookup, if auth changed, discard the stale checkpoint and flatten conversation history into userText. Layer 2 — Cross-account session cleanup: When a request arrives for a conversation whose session belongs to a different (now-exhausted) auth, close the old H2 stream and remove the stale session to free resources. Layer 3 — H2Stream.Err() exposure: New Err() method on H2Stream so callers can inspect RST_STREAM, GOAWAY, or other stream-level errors after closure. Layer 4 — processH2SessionFrames error propagation: Returns error instead of bare return. Connect EndStream errors (quota, rate limit) are now propagated instead of being logged and swallowed. Layer 5 — Pre-response transparent retry: If the stream fails before any data is sent to the client, return an error to the conductor so it retries with a different auth — fully transparent to the client. Layer 6 — Post-response error logging: If the stream fails after data was already sent, log a warning. The conductor's existing cooldown mechanism ensures the next request routes to a healthy account. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/auth/cursor/proto/h2stream.go | 4 + internal/runtime/executor/cursor_executor.go | 147 ++++++++++++++++--- 2 files changed, 128 insertions(+), 23 deletions(-) diff --git a/internal/auth/cursor/proto/h2stream.go b/internal/auth/cursor/proto/h2stream.go index be3f7905..45b5baf7 100644 --- a/internal/auth/cursor/proto/h2stream.go +++ b/internal/auth/cursor/proto/h2stream.go @@ -205,6 +205,10 @@ func (s *H2Stream) Data() <-chan []byte { return s.dataCh } // Done returns a channel closed when the stream ends. func (s *H2Stream) Done() <-chan struct{} { return s.doneCh } +// Err returns the error (if any) that caused the stream to close. +// Returns nil for a clean shutdown (EOF / StreamEnded). +func (s *H2Stream) Err() error { return s.err } + // Close tears down the connection. func (s *H2Stream) Close() { s.conn.Close() diff --git a/internal/runtime/executor/cursor_executor.go b/internal/runtime/executor/cursor_executor.go index 67987e7f..2f34ee05 100644 --- a/internal/runtime/executor/cursor_executor.go +++ b/internal/runtime/executor/cursor_executor.go @@ -51,6 +51,7 @@ type CursorExecutor struct { type savedCheckpoint struct { data []byte // raw ConversationStateStructure protobuf bytes blobStore map[string][]byte // blobs referenced by the checkpoint + authID string // auth that produced this checkpoint (checkpoint is auth-specific) updatedAt time.Time } @@ -126,6 +127,19 @@ func (e *CursorExecutor) cleanupLoop() { } } +// findSessionByConversationLocked searches for a session matching the given +// conversationId regardless of authID. Used to find and clean up stale sessions +// from a previous auth after quota failover. Caller must hold e.mu. +func (e *CursorExecutor) findSessionByConversationLocked(convId string) string { + suffix := ":" + convId + for k := range e.sessions { + if strings.HasSuffix(k, suffix) { + return k + } + } + return "" +} + // PrepareRequest implements ProviderExecutor (for HttpRequest support). func (e *CursorExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error { token := cursorAccessToken(auth) @@ -250,7 +264,7 @@ func (e *CursorExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r // Collect full text from streaming response var fullText strings.Builder - processH2SessionFrames(sessionCtx, stream, params.BlobStore, nil, + if streamErr := processH2SessionFrames(sessionCtx, stream, params.BlobStore, nil, func(text string, isThinking bool) { fullText.WriteString(text) }, @@ -258,7 +272,9 @@ func (e *CursorExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r nil, nil, // tokenUsage - non-streaming nil, // onCheckpoint - non-streaming doesn't persist - ) + ); streamErr != nil && fullText.Len() == 0 { + return resp, fmt.Errorf("cursor: stream error: %w", streamErr) + } id := "chatcmpl-" + uuid.New().String()[:28] created := time.Now().Unix() @@ -324,9 +340,10 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A authID := auth.ID // e.g. "cursor.json" or "cursor-account2.json" log.Debugf("cursor: conversationId=%s authID=%s", conversationId, authID) - // Include authID in keys for multi-account isolation + // Session key includes authID (H2 stream is auth-specific, not transferable). + // Checkpoint key uses conversationId only — allows detecting auth migration. sessionKey := authID + ":" + conversationId - checkpointKey := sessionKey // same isolation + checkpointKey := conversationId needsTranslate := from.String() != "" && from.String() != "openai" // Check if we can resume an existing session with tool results @@ -336,6 +353,20 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A if hasSession { delete(e.sessions, sessionKey) } + // If no session found for current auth, check for stale sessions from + // a different auth on the same conversation (quota failover scenario). + // Clean them up since the H2 stream belongs to the old account. + if !hasSession { + if oldKey := e.findSessionByConversationLocked(conversationId); oldKey != "" { + oldSession := e.sessions[oldKey] + log.Infof("cursor: cleaning up stale session from auth %s for conv=%s (auth migrated to %s)", oldSession.authID, conversationId, authID) + oldSession.cancel() + if oldSession.stream != nil { + oldSession.stream.Close() + } + delete(e.sessions, oldKey) + } + } e.mu.Unlock() if hasSession && session.stream != nil && session.authID == authID { @@ -347,23 +378,33 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A } } - // Clean up any stale session for this key + // Clean up any stale session for this key (or from a previous auth on same conversation) e.mu.Lock() if old, ok := e.sessions[sessionKey]; ok { old.cancel() delete(e.sessions, sessionKey) + } else if oldKey := e.findSessionByConversationLocked(conversationId); oldKey != "" { + old := e.sessions[oldKey] + old.cancel() + if old.stream != nil { + old.stream.Close() + } + delete(e.sessions, oldKey) } e.mu.Unlock() - // Look up saved checkpoint for this conversation + account + // Look up saved checkpoint for this conversation (keyed by conversationId only). + // Checkpoint is auth-specific: if auth changed (e.g. quota exhaustion failover), + // the old checkpoint is useless on the new account — discard and flatten. e.mu.Lock() saved, hasCheckpoint := e.checkpoints[checkpointKey] e.mu.Unlock() params := buildRunRequestParams(parsed, conversationId) - if hasCheckpoint && saved.data != nil { - log.Debugf("cursor: using saved checkpoint (%d bytes) for key=%s", len(saved.data), checkpointKey) + if hasCheckpoint && saved.data != nil && saved.authID == authID { + // Same auth — use checkpoint normally + log.Debugf("cursor: using saved checkpoint (%d bytes) for conv=%s auth=%s", len(saved.data), checkpointKey, authID) params.RawCheckpoint = saved.data // Merge saved blobStore into params if params.BlobStore == nil { @@ -374,6 +415,17 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A params.BlobStore[k] = v } } + } else if hasCheckpoint && saved.data != nil && saved.authID != authID { + // Auth changed (quota failover) — checkpoint is not portable across accounts. + // Discard and flatten conversation history into userText. + log.Infof("cursor: auth migrated (%s → %s) for conv=%s, discarding checkpoint and flattening context", saved.authID, authID, checkpointKey) + e.mu.Lock() + delete(e.checkpoints, checkpointKey) + e.mu.Unlock() + if len(parsed.ToolResults) > 0 || len(parsed.Turns) > 0 { + flattenConversationIntoUserText(parsed) + params = buildRunRequestParams(parsed, conversationId) + } } else if len(parsed.ToolResults) > 0 || len(parsed.Turns) > 0 { // Fallback: no checkpoint available (cold resume / proxy restart). // Flatten the full conversation history (including tool interactions) into userText. @@ -458,6 +510,21 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A } } + // Pre-response error detection for transparent failover: + // If the stream fails before any chunk is emitted (e.g. quota exceeded), + // ExecuteStream returns an error so the conductor retries with a different auth. + streamErrCh := make(chan error, 1) + firstChunkSent := make(chan struct{}, 1) // buffered: goroutine won't block signaling + + origEmitToOut := emitToOut + emitToOut = func(chunk cliproxyexecutor.StreamChunk) { + select { + case firstChunkSent <- struct{}{}: + default: + } + origEmitToOut(chunk) + } + go func() { var resumeOutCh chan cliproxyexecutor.StreamChunk _ = resumeOutCh @@ -466,7 +533,7 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A usage := &cursorTokenUsage{} usage.setInputEstimate(len(payload)) - processH2SessionFrames(sessionCtx, stream, params.BlobStore, params.McpTools, + streamErr := processH2SessionFrames(sessionCtx, stream, params.BlobStore, params.McpTools, func(text string, isThinking bool) { if isThinking { if !thinkingActive { @@ -537,19 +604,43 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A toolResultCh, usage, func(cpData []byte) { - // Save checkpoint for this conversation + // Save checkpoint keyed by conversationId, tagged with authID for migration detection e.mu.Lock() e.checkpoints[checkpointKey] = &savedCheckpoint{ data: cpData, blobStore: params.BlobStore, + authID: authID, updatedAt: time.Now(), } e.mu.Unlock() - log.Debugf("cursor: saved checkpoint (%d bytes) for key=%s", len(cpData), checkpointKey) + log.Debugf("cursor: saved checkpoint (%d bytes) for conv=%s auth=%s", len(cpData), checkpointKey, authID) }, ) - // processH2SessionFrames returned — stream is done + // processH2SessionFrames returned — stream is done. + // Check if error happened before any chunks were emitted. + if streamErr != nil { + select { + case <-firstChunkSent: + // Chunks were already sent to client — can't transparently retry. + // Next request will failover via conductor's cooldown mechanism. + log.Warnf("cursor: stream error after data sent (auth=%s conv=%s): %v", authID, conversationId, streamErr) + default: + // No data sent yet — propagate error for transparent conductor retry. + log.Warnf("cursor: stream error before data sent (auth=%s conv=%s): %v — signaling retry", authID, conversationId, streamErr) + streamErrCh <- streamErr + outMu.Lock() + if currentOut != nil { + close(currentOut) + currentOut = nil + } + outMu.Unlock() + sessionCancel() + stream.Close() + return + } + } + if thinkingActive { sendChunkSwitchable(`{"content":""}`, "") } @@ -584,7 +675,16 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A stream.Close() }() - return &cliproxyexecutor.StreamResult{Chunks: chunks}, nil + // Wait for either the first chunk or a pre-response error. + // If the stream fails before emitting any data (e.g. quota exceeded), + // return an error so the conductor retries with a different auth. + select { + case streamErr := <-streamErrCh: + return nil, fmt.Errorf("cursor: stream failed before response: %w", streamErr) + case <-firstChunkSent: + // Data started flowing — return stream to client + return &cliproxyexecutor.StreamResult{Chunks: chunks}, nil + } } // resumeWithToolResults injects tool results into the running processH2SessionFrames @@ -701,7 +801,7 @@ func processH2SessionFrames( toolResultCh <-chan []toolResultInfo, // nil for no tool result injection; non-nil to wait for results tokenUsage *cursorTokenUsage, // tracks accumulated token usage (may be nil) onCheckpoint func(data []byte), // called when server sends conversation_checkpoint_update -) { +) error { var buf bytes.Buffer rejectReason := "Tool not available in this environment. Use the MCP tools provided instead." log.Debugf("cursor: processH2SessionFrames started for streamID=%s, waiting for data...", stream.ID()) @@ -709,11 +809,11 @@ func processH2SessionFrames( select { case <-ctx.Done(): log.Debugf("cursor: processH2SessionFrames exiting: context done") - return + return ctx.Err() case data, ok := <-stream.Data(): if !ok { log.Debugf("cursor: processH2SessionFrames[%s]: exiting: stream data channel closed", stream.ID()) - return + return stream.Err() // may be RST_STREAM, GOAWAY, or nil for clean close } // Log first 20 bytes of raw data for debugging previewLen := min(20, len(data)) @@ -740,6 +840,7 @@ func processH2SessionFrames( if flags&cursorproto.ConnectEndStreamFlag != 0 { if err := cursorproto.ParseConnectEndStream(payload); err != nil { log.Warnf("cursor: connect end stream error: %v", err) + return err // propagate server-side errors (quota, rate limit, etc.) } continue } @@ -765,7 +866,7 @@ func processH2SessionFrames( case cursorproto.ServerMsgTurnEnded: log.Debugf("cursor: TurnEnded received, stream will finish") - return + return nil // clean completion case cursorproto.ServerMsgHeartbeat: // Server heartbeat, ignore silently @@ -818,7 +919,7 @@ func processH2SessionFrames( onMcpExec(pending) if toolResultCh == nil { - return + return nil } // Inline mode: wait for tool result while handling KV/heartbeat @@ -828,16 +929,16 @@ func processH2SessionFrames( for { select { case <-ctx.Done(): - return + return ctx.Err() case results, ok := <-toolResultCh: if !ok { - return + return nil } toolResults = results break waitLoop case waitData, ok := <-stream.Data(): if !ok { - return + return stream.Err() } buf.Write(waitData) for { @@ -875,7 +976,7 @@ func processH2SessionFrames( } } case <-stream.Done(): - return + return stream.Err() } } @@ -916,7 +1017,7 @@ func processH2SessionFrames( case <-stream.Done(): log.Debugf("cursor: processH2SessionFrames exiting: stream done") - return + return stream.Err() } } } From 1b7447b682090186a1963fcf49c719bf223abf42 Mon Sep 17 00:00:00 2001 From: MrHuangJser Date: Fri, 27 Mar 2026 11:42:22 +0800 Subject: [PATCH 12/13] feat(cursor): implement StatusError for conductor cooldown integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor executor errors were plain fmt.Errorf — the conductor couldn't extract HTTP status codes, so exhausted accounts never entered cooldown. Changes: - Add ConnectError struct to proto/connect.go: ParseConnectEndStream now returns *ConnectError with Code/Message fields for precise matching - Add cursorStatusErr implementing StatusError + RetryAfter interfaces - Add classifyCursorError() with two-layer classification: Layer 1: exact match on ConnectError.Code (gRPC standard codes) resource_exhausted → 429, unauthenticated → 401, permission_denied → 403, unavailable → 503, internal → 500 Layer 2: fuzzy string match for H2 errors (RST_STREAM → 502) - Log all ConnectError code/message pairs for observing real server error codes (we have no samples yet) - Wrap Execute and ExecuteStream error returns with classifyCursorError Now the conductor properly marks Cursor auths as cooldown on quota errors, enabling exponential backoff and round-robin failover. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/auth/cursor/proto/connect.go | 15 ++++- internal/runtime/executor/cursor_executor.go | 59 +++++++++++++++++++- 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/internal/auth/cursor/proto/connect.go b/internal/auth/cursor/proto/connect.go index db9e4288..ffe5905e 100644 --- a/internal/auth/cursor/proto/connect.go +++ b/internal/auth/cursor/proto/connect.go @@ -41,8 +41,21 @@ func ParseConnectFrame(buf []byte) (flags byte, payload []byte, consumed int, ok return flags, buf[5:total], total, true } +// ConnectError is a structured error from the Connect protocol end-of-stream trailer. +// The Code field contains the server-defined error code (e.g. gRPC standard codes +// like "resource_exhausted", "unauthenticated", "permission_denied", "unavailable"). +type ConnectError struct { + Code string // server-defined error code + Message string // human-readable error description +} + +func (e *ConnectError) Error() string { + return fmt.Sprintf("Connect error %s: %s", e.Code, e.Message) +} + // ParseConnectEndStream parses a Connect end-of-stream frame payload (JSON). // Returns nil if there is no error in the trailer. +// On error, returns a *ConnectError with the server's error code and message. func ParseConnectEndStream(data []byte) error { if len(data) == 0 { return nil @@ -65,7 +78,7 @@ func ParseConnectEndStream(data []byte) error { if msg == "" { msg = "Unknown error" } - return fmt.Errorf("Connect error %s: %s", code, msg) + return &ConnectError{Code: code, Message: msg} } return nil } diff --git a/internal/runtime/executor/cursor_executor.go b/internal/runtime/executor/cursor_executor.go index 2f34ee05..73335f50 100644 --- a/internal/runtime/executor/cursor_executor.go +++ b/internal/runtime/executor/cursor_executor.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "crypto/sha256" + "errors" "crypto/tls" "encoding/base64" "encoding/hex" @@ -140,6 +141,60 @@ func (e *CursorExecutor) findSessionByConversationLocked(convId string) string { return "" } +// cursorStatusErr implements the StatusError and RetryAfter interfaces so the +// conductor can classify Cursor errors (e.g. 429 → quota cooldown). +type cursorStatusErr struct { + code int + msg string +} + +func (e cursorStatusErr) Error() string { return e.msg } +func (e cursorStatusErr) StatusCode() int { return e.code } +func (e cursorStatusErr) RetryAfter() *time.Duration { return nil } // no retry-after info from Cursor; conductor uses exponential backoff + +// classifyCursorError maps Cursor Connect/H2 errors to HTTP status codes. +// Layer 1: precise match on ConnectError.Code (gRPC standard codes). +// Layer 2: fuzzy string match for H2 frame errors and unknown formats. +// Unclassified errors pass through unchanged. +func classifyCursorError(err error) error { + if err == nil { + return nil + } + + // Layer 1: structured ConnectError from ParseConnectEndStream + var ce *cursorproto.ConnectError + if errors.As(err, &ce) { + log.Infof("cursor: Connect error code=%q message=%q", ce.Code, ce.Message) + switch ce.Code { + case "resource_exhausted": + return cursorStatusErr{code: 429, msg: err.Error()} + case "unauthenticated": + return cursorStatusErr{code: 401, msg: err.Error()} + case "permission_denied": + return cursorStatusErr{code: 403, msg: err.Error()} + case "unavailable": + return cursorStatusErr{code: 503, msg: err.Error()} + case "internal": + return cursorStatusErr{code: 500, msg: err.Error()} + default: + // Unknown Connect code — log for observation, treat as 502 + return cursorStatusErr{code: 502, msg: err.Error()} + } + } + + // Layer 2: fuzzy match for H2 errors and unstructured messages + msg := strings.ToLower(err.Error()) + switch { + case strings.Contains(msg, "rate limit") || strings.Contains(msg, "quota") || + strings.Contains(msg, "too many"): + return cursorStatusErr{code: 429, msg: err.Error()} + case strings.Contains(msg, "rst_stream") || strings.Contains(msg, "goaway"): + return cursorStatusErr{code: 502, msg: err.Error()} + } + + return err +} + // PrepareRequest implements ProviderExecutor (for HttpRequest support). func (e *CursorExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error { token := cursorAccessToken(auth) @@ -273,7 +328,7 @@ func (e *CursorExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r nil, // tokenUsage - non-streaming nil, // onCheckpoint - non-streaming doesn't persist ); streamErr != nil && fullText.Len() == 0 { - return resp, fmt.Errorf("cursor: stream error: %w", streamErr) + return resp, classifyCursorError(fmt.Errorf("cursor: stream error: %w", streamErr)) } id := "chatcmpl-" + uuid.New().String()[:28] @@ -680,7 +735,7 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A // return an error so the conductor retries with a different auth. select { case streamErr := <-streamErrCh: - return nil, fmt.Errorf("cursor: stream failed before response: %w", streamErr) + return nil, classifyCursorError(fmt.Errorf("cursor: stream failed before response: %w", streamErr)) case <-firstChunkSent: // Data started flowing — return stream to client return &cliproxyexecutor.StreamResult{Chunks: chunks}, nil From 7386a70724a8d968eb4e5511e1de6b78bd6a91b1 Mon Sep 17 00:00:00 2001 From: MrHuangJser Date: Fri, 27 Mar 2026 17:40:02 +0800 Subject: [PATCH 13/13] feat(cursor): auto-identify accounts from JWT sub for multi-account support Previously Cursor required a manual ?label=xxx parameter to distinguish accounts (unlike Codex which auto-generates filenames from JWT claims). Cursor JWTs contain a "sub" claim (e.g. "auth0|user_XXXX") that uniquely identifies each account. Now we: - Add ParseJWTSub() + SubToShortHash() to extract and hash the sub claim - Refactor GetTokenExpiry() to share the new decodeJWTPayload() helper - Update CredentialFileName(label, subHash) to auto-generate filenames from the sub hash when no explicit label is provided (e.g. "cursor.8f202e67.json" instead of always "cursor.json") - Add DisplayLabel() for human-readable account identification - Store "sub" in metadata for observability - Update both management API handler and SDK authenticator Same account always produces the same filename (deterministic), different accounts get different files. Explicit ?label= still takes priority. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../api/handlers/management/auth_files.go | 14 ++++-- internal/auth/cursor/filename.go | 27 ++++++++-- internal/auth/cursor/oauth.go | 49 +++++++++++++++---- sdk/auth/cursor.go | 11 ++++- 4 files changed, 80 insertions(+), 21 deletions(-) diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index 91e1c425..1c2abd34 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -3736,17 +3736,21 @@ func (h *Handler) RequestCursorToken(c *gin.Context) { "timestamp": time.Now().UnixMilli(), } - // Extract expiry from JWT + // Extract expiry and account identity from JWT expiry := cursorauth.GetTokenExpiry(tokens.AccessToken) if !expiry.IsZero() { metadata["expires_at"] = expiry.Format(time.RFC3339) } - fileName := cursorauth.CredentialFileName(label) - displayLabel := "Cursor User" - if label != "" { - displayLabel = "Cursor " + label + // Auto-identify account from JWT sub claim for multi-account support + sub := cursorauth.ParseJWTSub(tokens.AccessToken) + subHash := cursorauth.SubToShortHash(sub) + if sub != "" { + metadata["sub"] = sub } + + fileName := cursorauth.CredentialFileName(label, subHash) + displayLabel := cursorauth.DisplayLabel(label, subHash) record := &coreauth.Auth{ ID: fileName, Provider: "cursor", diff --git a/internal/auth/cursor/filename.go b/internal/auth/cursor/filename.go index 47cce08b..e8fb8415 100644 --- a/internal/auth/cursor/filename.go +++ b/internal/auth/cursor/filename.go @@ -6,11 +6,28 @@ import ( ) // CredentialFileName returns the filename used to persist Cursor credentials. -// It uses the label as a suffix to disambiguate multiple accounts. -func CredentialFileName(label string) string { +// Priority: explicit label > auto-generated from JWT sub hash. +// If both label and subHash are empty, falls back to "cursor.json". +func CredentialFileName(label, subHash string) string { label = strings.TrimSpace(label) - if label == "" { - return "cursor.json" + subHash = strings.TrimSpace(subHash) + if label != "" { + return fmt.Sprintf("cursor.%s.json", label) } - return fmt.Sprintf("cursor-%s.json", label) + if subHash != "" { + return fmt.Sprintf("cursor.%s.json", subHash) + } + return "cursor.json" +} + +// DisplayLabel returns a human-readable label for the Cursor account. +func DisplayLabel(label, subHash string) string { + label = strings.TrimSpace(label) + if label != "" { + return "Cursor " + label + } + if subHash != "" { + return "Cursor " + subHash + } + return "Cursor User" } diff --git a/internal/auth/cursor/oauth.go b/internal/auth/cursor/oauth.go index 065eff7e..009dda01 100644 --- a/internal/auth/cursor/oauth.go +++ b/internal/auth/cursor/oauth.go @@ -171,29 +171,60 @@ func RefreshToken(ctx context.Context, refreshToken string) (*TokenPair, error) return &tokens, nil } -// GetTokenExpiry extracts the JWT expiry from an access token with a 5-minute safety margin. -// Falls back to 1 hour from now if the token can't be parsed. -func GetTokenExpiry(token string) time.Time { +// ParseJWTSub extracts the "sub" claim from a Cursor JWT access token. +// Cursor JWTs contain "sub" like "auth0|user_XXXX" which uniquely identifies +// the account. Returns empty string if parsing fails. +func ParseJWTSub(token string) string { + decoded := decodeJWTPayload(token) + if decoded == nil { + return "" + } + var claims struct { + Sub string `json:"sub"` + } + if err := json.Unmarshal(decoded, &claims); err != nil { + return "" + } + return claims.Sub +} + +// SubToShortHash converts a JWT sub claim to a short hex hash for use in filenames. +// e.g. "auth0|user_2x..." → "a3f8b2c1" +func SubToShortHash(sub string) string { + if sub == "" { + return "" + } + h := sha256.Sum256([]byte(sub)) + return fmt.Sprintf("%x", h[:4]) // 8 hex chars +} + +// decodeJWTPayload decodes the payload (middle) part of a JWT. +func decodeJWTPayload(token string) []byte { parts := strings.Split(token, ".") if len(parts) != 3 { - return time.Now().Add(1 * time.Hour) + return nil } - - // Decode the payload (middle part) payload := parts[1] - // Add padding if needed switch len(payload) % 4 { case 2: payload += "==" case 3: payload += "=" } - // Replace URL-safe characters payload = strings.ReplaceAll(payload, "-", "+") payload = strings.ReplaceAll(payload, "_", "/") - decoded, err := base64.StdEncoding.DecodeString(payload) if err != nil { + return nil + } + return decoded +} + +// GetTokenExpiry extracts the JWT expiry from an access token with a 5-minute safety margin. +// Falls back to 1 hour from now if the token can't be parsed. +func GetTokenExpiry(token string) time.Time { + decoded := decodeJWTPayload(token) + if decoded == nil { return time.Now().Add(1 * time.Hour) } diff --git a/sdk/auth/cursor.go b/sdk/auth/cursor.go index d6077be6..5e26221c 100644 --- a/sdk/auth/cursor.go +++ b/sdk/auth/cursor.go @@ -69,6 +69,10 @@ func (a CursorAuthenticator) Login(ctx context.Context, cfg *config.Config, opts expiresAt := cursorauth.GetTokenExpiry(tokens.AccessToken) + // Auto-identify account from JWT sub claim + sub := cursorauth.ParseJWTSub(tokens.AccessToken) + subHash := cursorauth.SubToShortHash(sub) + log.Info("Cursor authentication successful!") metadata := map[string]any{ @@ -78,14 +82,17 @@ func (a CursorAuthenticator) Login(ctx context.Context, cfg *config.Config, opts "expires_at": expiresAt.Format(time.RFC3339), "timestamp": time.Now().UnixMilli(), } + if sub != "" { + metadata["sub"] = sub + } - fileName := "cursor.json" + fileName := cursorauth.CredentialFileName("", subHash) return &coreauth.Auth{ ID: fileName, Provider: a.Provider(), FileName: fileName, - Label: "cursor-user", + Label: cursorauth.DisplayLabel("", subHash), Metadata: metadata, }, nil }