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:
黄姜恒
2026-03-26 11:27:49 +08:00
parent dcfbec2990
commit de5fe71478
3 changed files with 41 additions and 12 deletions

View File

@@ -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)

View 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)
}

View File

@@ -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)
},
)