History

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

黄姜恒 committed 7 months ago

Commit de5fe711 parent dcfbec23 files · +41 −12Browse files

Unified · Split

3 files
  1. internal/api/handlers/management/auth_files.go +9 −3
  2. internal/auth/cursor/filename.go +16 −0
  3. internal/runtime/executor/cursor_executor.go +16 −9
internal/api/handlers/management/auth_files.go+9−3
@@ -3697,13 +3697,15 @@ func (h *Handler) RequestKiloToken(c *gin.Context) { · 3696 unmodified lines
36973697 }
36983698
36993699 // RequestCursorToken initiates the Cursor PKCE authentication flow.
3700+// Supports multiple accounts via ?label=xxx query parameter.
37003701 // The user opens the returned URL in a browser, logs in, and the server polls
37013702 // until the authentication completes.
37023703 func (h *Handler) RequestCursorToken(c *gin.Context) {
37033704 ctx := context.Background()
37043705 ctx = PopulateAuthContext(ctx, c)
37053706
3706− fmt.Println("Initializing Cursor authentication...")
3707+ label := strings.TrimSpace(c.Query("label"))
3708+ fmt.Printf("Initializing Cursor authentication (label=%q)...\n", label)
37073709
37083710 authParams, err := cursorauth.GenerateAuthParams()
37093711 if err != nil {
@@ -3740,12 +3742,16 @@ func (h *Handler) RequestCursorToken(c *gin.Context) { · 30 unmodified lines
37403742 metadata["expires_at"] = expiry.Format(time.RFC3339)
37413743 }
37423744
3743− fileName := "cursor.json"
3745+ fileName := cursorauth.CredentialFileName(label)
3746+ displayLabel := "Cursor User"
3747+ if label != "" {
3748+ displayLabel = "Cursor " + label
3749+ }
37443750 record := &coreauth.Auth{
37453751 ID: fileName,
37463752 Provider: "cursor",
37473753 FileName: fileName,
3748− Label: "Cursor User",
3754+ Label: displayLabel,
37493755 Metadata: metadata,
37503756 }
37513757 savedPath, errSave := h.saveTokenRecord(ctx, record)
internal/auth/cursor/filename.go+16−0
@@ -0,0 +1,16 @@
1+package cursor
2+
3+import (
4+ "fmt"
5+ "strings"
6+)
7+
8+// CredentialFileName returns the filename used to persist Cursor credentials.
9+// It uses the label as a suffix to disambiguate multiple accounts.
10+func CredentialFileName(label string) string {
11+ label = strings.TrimSpace(label)
12+ if label == "" {
13+ return "cursor.json"
14+ }
15+ return fmt.Sprintf("cursor-%s.json", label)
16+}
internal/runtime/executor/cursor_executor.go+16−9
@@ -61,6 +61,7 @@ type cursorSession struct { · 60 unmodified lines
6161 pending []pendingMcpExec
6262 cancel context.CancelFunc // cancels the session-scoped heartbeat (NOT tied to HTTP request)
6363 createdAt time.Time
64+ authID string // auth file ID that created this session (for multi-account isolation)
6465 toolResultCh chan []toolResultInfo // receives tool results from the next HTTP request
6566 resumeOutCh chan cliproxyexecutor.StreamChunk // output channel for resumed response
6667 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 · 253 unmodified lines
320321 parsed.Model, len(parsed.UserText), len(parsed.Turns), len(parsed.Tools), len(parsed.ToolResults))
321322
322323 conversationId := deriveConversationId(apiKeyFromContext(ctx), ccSessionId, parsed.SystemPrompt)
323− log.Debugf("cursor: conversationId=%s ccSessionId=%s", conversationId, ccSessionId)
324+ authID := auth.ID // e.g. "cursor.json" or "cursor-account2.json"
325+ log.Debugf("cursor: conversationId=%s authID=%s", conversationId, authID)
324326
325− // Use conversationId as session key — stable across requests in the same Claude Code session
326− sessionKey := conversationId
327+ // Include authID in keys for multi-account isolation
328+ sessionKey := authID + ":" + conversationId
329+ checkpointKey := sessionKey // same isolation
327330 needsTranslate := from.String() != "" && from.String() != "openai"
328331
329332 // 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 · 5 unmodified lines
335338 }
336339 e.mu.Unlock()
337340
338− if hasSession && session.stream != nil {
341+ if hasSession && session.stream != nil && session.authID == authID {
339342 log.Debugf("cursor: resuming session %s with %d tool results", sessionKey, len(parsed.ToolResults))
340343 return e.resumeWithToolResults(ctx, session, parsed, from, to, req, originalPayload, payload, needsTranslate)
341344 }
345+ if hasSession && session.authID != authID {
346+ log.Warnf("cursor: session %s belongs to auth %s, but request is from %s — skipping resume", sessionKey, session.authID, authID)
347+ }
342348 }
343349
344350 // Clean up any stale session for this key
@@ -349,15 +355,15 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A · 4 unmodified lines
349355 }
350356 e.mu.Unlock()
351357
352− // Look up saved checkpoint for this conversation
358+ // Look up saved checkpoint for this conversation + account
353359 e.mu.Lock()
354− saved, hasCheckpoint := e.checkpoints[conversationId]
360+ saved, hasCheckpoint := e.checkpoints[checkpointKey]
355361 e.mu.Unlock()
356362
357363 params := buildRunRequestParams(parsed, conversationId)
358364
359365 if hasCheckpoint && saved.data != nil {
360− log.Debugf("cursor: using saved checkpoint (%d bytes) for conversationId=%s", len(saved.data), conversationId)
366+ log.Debugf("cursor: using saved checkpoint (%d bytes) for key=%s", len(saved.data), checkpointKey)
361367 params.RawCheckpoint = saved.data
362368 // Merge saved blobStore into params
363369 if params.BlobStore == nil {
@@ -507,6 +513,7 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A · 143 unmodified lines
507513 pending: []pendingMcpExec{exec},
508514 cancel: sessionCancel,
509515 createdAt: time.Now(),
516+ authID: authID,
510517 toolResultCh: toolResultCh, // reuse same channel across rounds
511518 resumeOutCh: resumeOut,
512519 switchOutput: func(ch chan cliproxyexecutor.StreamChunk) {
@@ -532,13 +539,13 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A · 19 unmodified lines
532539 func(cpData []byte) {
533540 // Save checkpoint for this conversation
534541 e.mu.Lock()
535− e.checkpoints[conversationId] = &savedCheckpoint{
542+ e.checkpoints[checkpointKey] = &savedCheckpoint{
536543 data: cpData,
537544 blobStore: params.BlobStore,
538545 updatedAt: time.Now(),
539546 }
540547 e.mu.Unlock()
541− log.Debugf("cursor: saved checkpoint (%d bytes) for conversationId=%s", len(cpData), conversationId)
548+ log.Debugf("cursor: saved checkpoint (%d bytes) for key=%s", len(cpData), checkpointKey)
542549 },
543550 )
544551