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