mirror of
https://github.com/router-for-me/CLIProxyAPIPlus.git
synced 2026-04-02 02:36:11 +00:00
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
16
internal/auth/cursor/filename.go
Normal file
16
internal/auth/cursor/filename.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user