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. // 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)

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