mirror of
https://github.com/router-for-me/CLIProxyAPIPlus.git
synced 2026-05-07 07:16:20 +08: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.
|
// 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
|
// The user opens the returned URL in a browser, logs in, and the server polls
|
||||||
// until the authentication completes.
|
// until the authentication completes.
|
||||||
func (h *Handler) RequestCursorToken(c *gin.Context) {
|
func (h *Handler) RequestCursorToken(c *gin.Context) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
ctx = PopulateAuthContext(ctx, c)
|
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()
|
authParams, err := cursorauth.GenerateAuthParams()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -3740,12 +3742,16 @@ func (h *Handler) RequestCursorToken(c *gin.Context) {
|
|||||||
metadata["expires_at"] = expiry.Format(time.RFC3339)
|
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{
|
record := &coreauth.Auth{
|
||||||
ID: fileName,
|
ID: fileName,
|
||||||
Provider: "cursor",
|
Provider: "cursor",
|
||||||
FileName: fileName,
|
FileName: fileName,
|
||||||
Label: "Cursor User",
|
Label: displayLabel,
|
||||||
Metadata: metadata,
|
Metadata: metadata,
|
||||||
}
|
}
|
||||||
savedPath, errSave := h.saveTokenRecord(ctx, record)
|
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
|
pending []pendingMcpExec
|
||||||
cancel context.CancelFunc // cancels the session-scoped heartbeat (NOT tied to HTTP request)
|
cancel context.CancelFunc // cancels the session-scoped heartbeat (NOT tied to HTTP request)
|
||||||
createdAt time.Time
|
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
|
toolResultCh chan []toolResultInfo // receives tool results from the next HTTP request
|
||||||
resumeOutCh chan cliproxyexecutor.StreamChunk // output channel for resumed response
|
resumeOutCh chan cliproxyexecutor.StreamChunk // output channel for resumed response
|
||||||
switchOutput func(ch chan cliproxyexecutor.StreamChunk) // callback to switch output channel
|
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))
|
parsed.Model, len(parsed.UserText), len(parsed.Turns), len(parsed.Tools), len(parsed.ToolResults))
|
||||||
|
|
||||||
conversationId := deriveConversationId(apiKeyFromContext(ctx), ccSessionId, parsed.SystemPrompt)
|
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
|
// Include authID in keys for multi-account isolation
|
||||||
sessionKey := conversationId
|
sessionKey := authID + ":" + conversationId
|
||||||
|
checkpointKey := sessionKey // same isolation
|
||||||
needsTranslate := from.String() != "" && from.String() != "openai"
|
needsTranslate := from.String() != "" && from.String() != "openai"
|
||||||
|
|
||||||
// Check if we can resume an existing session with tool results
|
// 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()
|
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))
|
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)
|
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
|
// 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()
|
e.mu.Unlock()
|
||||||
|
|
||||||
// Look up saved checkpoint for this conversation
|
// Look up saved checkpoint for this conversation + account
|
||||||
e.mu.Lock()
|
e.mu.Lock()
|
||||||
saved, hasCheckpoint := e.checkpoints[conversationId]
|
saved, hasCheckpoint := e.checkpoints[checkpointKey]
|
||||||
e.mu.Unlock()
|
e.mu.Unlock()
|
||||||
|
|
||||||
params := buildRunRequestParams(parsed, conversationId)
|
params := buildRunRequestParams(parsed, conversationId)
|
||||||
|
|
||||||
if hasCheckpoint && saved.data != nil {
|
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
|
params.RawCheckpoint = saved.data
|
||||||
// Merge saved blobStore into params
|
// Merge saved blobStore into params
|
||||||
if params.BlobStore == nil {
|
if params.BlobStore == nil {
|
||||||
@@ -507,6 +513,7 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
|
|||||||
pending: []pendingMcpExec{exec},
|
pending: []pendingMcpExec{exec},
|
||||||
cancel: sessionCancel,
|
cancel: sessionCancel,
|
||||||
createdAt: time.Now(),
|
createdAt: time.Now(),
|
||||||
|
authID: authID,
|
||||||
toolResultCh: toolResultCh, // reuse same channel across rounds
|
toolResultCh: toolResultCh, // reuse same channel across rounds
|
||||||
resumeOutCh: resumeOut,
|
resumeOutCh: resumeOut,
|
||||||
switchOutput: func(ch chan cliproxyexecutor.StreamChunk) {
|
switchOutput: func(ch chan cliproxyexecutor.StreamChunk) {
|
||||||
@@ -532,13 +539,13 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
|
|||||||
func(cpData []byte) {
|
func(cpData []byte) {
|
||||||
// Save checkpoint for this conversation
|
// Save checkpoint for this conversation
|
||||||
e.mu.Lock()
|
e.mu.Lock()
|
||||||
e.checkpoints[conversationId] = &savedCheckpoint{
|
e.checkpoints[checkpointKey] = &savedCheckpoint{
|
||||||
data: cpData,
|
data: cpData,
|
||||||
blobStore: params.BlobStore,
|
blobStore: params.BlobStore,
|
||||||
updatedAt: time.Now(),
|
updatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
e.mu.Unlock()
|
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