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
3697}3697}
3698 3698
3699// RequestCursorToken initiates the Cursor PKCE authentication flow.3699// RequestCursorToken initiates the Cursor PKCE authentication flow.
3700// Supports multiple accounts via ?label=xxx query parameter.
3700// The user opens the returned URL in a browser, logs in, and the server polls3701// The user opens the returned URL in a browser, logs in, and the server polls
3701// until the authentication completes.3702// until the authentication completes.
3702func (h *Handler) RequestCursorToken(c *gin.Context) {3703func (h *Handler) RequestCursorToken(c *gin.Context) {
3703 ctx := context.Background()3704 ctx := context.Background()
3704 ctx = PopulateAuthContext(ctx, c)3705 ctx = PopulateAuthContext(ctx, c)
3705 3706
3706 fmt.Println("Initializing Cursor authentication...")3707 label := strings.TrimSpace(c.Query("label"))
3708 fmt.Printf("Initializing Cursor authentication (label=%q)...\n", label)
3707 3709
3708 authParams, err := cursorauth.GenerateAuthParams()3710 authParams, err := cursorauth.GenerateAuthParams()
3709 if err != nil {3711 if err != nil {
@@ -3740,12 +3742,16 @@ func (h *Handler) RequestCursorToken(c *gin.Context) { · 30 unmodified lines
3740 metadata["expires_at"] = expiry.Format(time.RFC3339)3742 metadata["expires_at"] = expiry.Format(time.RFC3339)
3741 }3743 }
3742 3744
3743 fileName := "cursor.json"3745 fileName := cursorauth.CredentialFileName(label)
3746 displayLabel := "Cursor User"
3747 if label != "" {
3748 displayLabel = "Cursor " + label
3749 }
3744 record := &coreauth.Auth{3750 record := &coreauth.Auth{
3745 ID: fileName,3751 ID: fileName,
3746 Provider: "cursor",3752 Provider: "cursor",
3747 FileName: fileName,3753 FileName: fileName,
3748 Label: "Cursor User",3754 Label: displayLabel,
3749 Metadata: metadata,3755 Metadata: metadata,
3750 }3756 }
3751 savedPath, errSave := h.saveTokenRecord(ctx, record)3757 savedPath, errSave := h.saveTokenRecord(ctx, record)
internal/auth/cursor/filename.go+16−0
@@ -0,0 +1,16 @@
1package cursor
2
3import (
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.
10func 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
61 pending []pendingMcpExec61 pending []pendingMcpExec
62 cancel context.CancelFunc // cancels the session-scoped heartbeat (NOT tied to HTTP request)62 cancel context.CancelFunc // cancels the session-scoped heartbeat (NOT tied to HTTP request)
63 createdAt time.Time63 createdAt time.Time
64 authID string // auth file ID that created this session (for multi-account isolation)
64 toolResultCh chan []toolResultInfo // receives tool results from the next HTTP request65 toolResultCh chan []toolResultInfo // receives tool results from the next HTTP request
65 resumeOutCh chan cliproxyexecutor.StreamChunk // output channel for resumed response66 resumeOutCh chan cliproxyexecutor.StreamChunk // output channel for resumed response
66 switchOutput func(ch chan cliproxyexecutor.StreamChunk) // callback to switch output channel67 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
320 parsed.Model, len(parsed.UserText), len(parsed.Turns), len(parsed.Tools), len(parsed.ToolResults))321 parsed.Model, len(parsed.UserText), len(parsed.Turns), len(parsed.Tools), len(parsed.ToolResults))
321 322
322 conversationId := deriveConversationId(apiKeyFromContext(ctx), ccSessionId, parsed.SystemPrompt)323 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)
324 326
325 // Use conversationId as session key — stable across requests in the same Claude Code session327 // Include authID in keys for multi-account isolation
326 sessionKey := conversationId328 sessionKey := authID + ":" + conversationId
329 checkpointKey := sessionKey // same isolation
327 needsTranslate := from.String() != "" && from.String() != "openai"330 needsTranslate := from.String() != "" && from.String() != "openai"
328 331
329 // Check if we can resume an existing session with tool results332 // 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
335 }338 }
336 e.mu.Unlock()339 e.mu.Unlock()
337 340
338 if hasSession && session.stream != nil {341 if hasSession && session.stream != nil && session.authID == authID {
339 log.Debugf("cursor: resuming session %s with %d tool results", sessionKey, len(parsed.ToolResults))342 log.Debugf("cursor: resuming session %s with %d tool results", sessionKey, len(parsed.ToolResults))
340 return e.resumeWithToolResults(ctx, session, parsed, from, to, req, originalPayload, payload, needsTranslate)343 return e.resumeWithToolResults(ctx, session, parsed, from, to, req, originalPayload, payload, needsTranslate)
341 }344 }
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 }
342 }348 }
343 349
344 // Clean up any stale session for this key350 // 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
349 }355 }
350 e.mu.Unlock()356 e.mu.Unlock()
351 357
352 // Look up saved checkpoint for this conversation358 // Look up saved checkpoint for this conversation + account
353 e.mu.Lock()359 e.mu.Lock()
354 saved, hasCheckpoint := e.checkpoints[conversationId]360 saved, hasCheckpoint := e.checkpoints[checkpointKey]
355 e.mu.Unlock()361 e.mu.Unlock()
356 362
357 params := buildRunRequestParams(parsed, conversationId)363 params := buildRunRequestParams(parsed, conversationId)
358 364
359 if hasCheckpoint && saved.data != nil {365 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)
361 params.RawCheckpoint = saved.data367 params.RawCheckpoint = saved.data
362 // Merge saved blobStore into params368 // Merge saved blobStore into params
363 if params.BlobStore == nil {369 if params.BlobStore == nil {
@@ -507,6 +513,7 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A · 143 unmodified lines
507 pending: []pendingMcpExec{exec},513 pending: []pendingMcpExec{exec},
508 cancel: sessionCancel,514 cancel: sessionCancel,
509 createdAt: time.Now(),515 createdAt: time.Now(),
516 authID: authID,
510 toolResultCh: toolResultCh, // reuse same channel across rounds517 toolResultCh: toolResultCh, // reuse same channel across rounds
511 resumeOutCh: resumeOut,518 resumeOutCh: resumeOut,
512 switchOutput: func(ch chan cliproxyexecutor.StreamChunk) {519 switchOutput: func(ch chan cliproxyexecutor.StreamChunk) {
@@ -532,13 +539,13 @@ func (e *CursorExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A · 19 unmodified lines
532 func(cpData []byte) {539 func(cpData []byte) {
533 // Save checkpoint for this conversation540 // Save checkpoint for this conversation
534 e.mu.Lock()541 e.mu.Lock()
535 e.checkpoints[conversationId] = &savedCheckpoint{542 e.checkpoints[checkpointKey] = &savedCheckpoint{
536 data: cpData,543 data: cpData,
537 blobStore: params.BlobStore,544 blobStore: params.BlobStore,
538 updatedAt: time.Now(),545 updatedAt: time.Now(),
539 }546 }
540 e.mu.Unlock()547 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)
542 },549 },
543 )550 )
544 551