From f7578789ef0e408351a396474b6a54690375dc9e Mon Sep 17 00:00:00 2001 From: luvs01 Date: Sun, 20 Sep 2026 08:48:53 +0900 Subject: [PATCH] fix(cursor): isolate live roster and Max Mode evidence by account Pooled Cursor accounts shared two module-level singletons for live roster state: the Claude wire-spelling map and the Max-Mode evidence set. A discovery recorded under one credential could silently rewrite the wire id or arm ultra for a request resolved under a different account. Key both maps by a non-secret sha256 scope over the upstream destination and credential, thread the scope from the adapter through createCursorRequest into resolveCursorSelection, record discoveries under the accepting provider scope, and drop scoped evidence when a provider model cache clears or is reconciled away. A scoped request with no recorded slot resolves against an empty evidence set, never the global slot. Unscoped callers keep the legacy global maps. --- src/adapters/cursor.ts | 13 ++- src/adapters/cursor/catalog.ts | 79 +++++++++++++++++-- src/adapters/cursor/request-builder.ts | 13 ++- src/codex/catalog/provider-models.ts | 7 +- src/codex/model-cache.ts | 6 ++ tests/providers/cursor/cursor-catalog.test.ts | 71 +++++++++++++++++ 6 files changed, 174 insertions(+), 15 deletions(-) diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index 17f9fd45fd7..e495f2a6713 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -61,6 +61,7 @@ import { CursorTransportDisabledError, type CursorTransportFactory, } from "./cursor/transport"; +import { cursorLiveRosterScope } from "./cursor/catalog"; export const CURSOR_API_URL = "https://api2.cursor.sh"; @@ -172,9 +173,11 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda // Namespace thread→conversation derivation by the authenticated Cursor credential so // shared-proxy tenants with different Cursor accounts cannot collide on a parent thread id. // Prefer an already-set auth scope (e.g. Codex pool account) when present. + let liveRosterScope: string | undefined; if (!_parsed._cursorIdentityScope) { try { const token = resolveCursorToken(provider, incoming.headers); + liveRosterScope = cursorLiveRosterScope(provider.baseUrl, token); _parsed._cursorIdentityScope = createHash("sha256") .update("ocx:cursor:acct:") .update(token) @@ -183,11 +186,17 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda } catch { /* Missing credential is handled by the live transport path below. */ } + } else { + try { + liveRosterScope = cursorLiveRosterScope(provider.baseUrl, resolveCursorToken(provider, incoming.headers)); + } catch { + /* Missing credential is handled by the live transport path below. */ + } } const inheritedCheckpointRef = _parsed._providerContinuation?.cursor?.checkpointRef; const previousConversationId = _parsed._cursorConversationId; let request = { - ...createCursorRequest(_parsed), + ...createCursorRequest(_parsed, { liveRosterScope }), _cursorIdentityScope: _parsed._cursorIdentityScope?.trim() || "local", }; requestSizeContext = cursorRequestSizeContext(request); @@ -441,7 +450,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda lastTransport = undefined; _parsed._cursorConversationId = undefined; const next = { - ...createCursorRequest(_parsed, { forceFreshConversation: true }), + ...createCursorRequest(_parsed, { forceFreshConversation: true, liveRosterScope }), _cursorIdentityScope: _parsed._cursorIdentityScope?.trim() || "local", }; rekeyContextUsage(failedConversationId, next.conversationId); diff --git a/src/adapters/cursor/catalog.ts b/src/adapters/cursor/catalog.ts index 69791f087b8..0e744692513 100644 --- a/src/adapters/cursor/catalog.ts +++ b/src/adapters/cursor/catalog.ts @@ -3,6 +3,7 @@ import { normalizeCursorClaudeId, type NormalizedCursorClaudeId, } from "./claude-id"; +import { createHash } from "node:crypto"; /** * Cursor umbrella catalog — the single source of truth for cursor model @@ -594,12 +595,16 @@ function composeWireId( * * `liveMaxModeIds` optionally extends the static maxMode evidence with the * bases the live GetUsableModels roster flags (union semantics). + * + * A scoped request (`options.liveRosterScope`) reads only that credential's + * recorded evidence: an absent scope slot is an empty set, never the global + * slot — account isolation is the point of the scope. */ export function resolveCursorSelection( pickedId: string, reasoning: string | undefined, liveMaxModeIds?: ReadonlySet, - options: { fast?: boolean } = {}, + options: { fast?: boolean; liveRosterScope?: string } = {}, ): CursorResolvedSelection { const parsed = parseCursorVariantId(pickedId); if (!parsed.known) { @@ -617,7 +622,10 @@ export function resolveCursorSelection( const requested = parsed.level ?? reasoning; const effort = cursorVariantEffort(spec, requested); const requestedClaude = normalizeCursorClaudeId(pickedId); - const claudeIdentity = liveCursorClaudeWireIdentities.get(parsed.baseId) + const scopedClaudeIdentities = options.liveRosterScope + ? liveCursorClaudeWireIdentitiesByScope.get(options.liveRosterScope) + : liveCursorClaudeWireIdentities; + const claudeIdentity = scopedClaudeIdentities?.get(parsed.baseId) ?? (requestedClaude ? { sourceBaseId: requestedClaude.sourceBaseId, spelling: requestedClaude.spelling } : undefined); @@ -626,7 +634,10 @@ export function resolveCursorSelection( ? `${capability.wirePrefix}${canonicalId}` : canonicalId; const ultraRequested = parsed.ultra || reasoning?.toLowerCase() === "ultra"; - const evidence = liveMaxModeIds ?? liveCursorMaxModeBases; + const evidence = liveMaxModeIds + ?? (options.liveRosterScope + ? liveCursorMaxModeBasesByScope.get(options.liveRosterScope) ?? EMPTY_LIVE_MAX_MODE_EVIDENCE + : liveCursorMaxModeBases); const maxModeArmed = capability.maxModeVerified === true || evidence.has(parsed.baseId); return { wireId, canonicalId, maxMode: ultraRequested && maxModeArmed, known: true }; } @@ -639,15 +650,47 @@ export function resolveCursorSelection( */ let liveCursorMaxModeBases: ReadonlySet = new Set(); let liveCursorClaudeWireIdentities: ReadonlyMap = new Map(); +const liveCursorMaxModeBasesByScope = new Map>(); +const liveCursorClaudeWireIdentitiesByScope = new Map>(); +const liveCursorRosterScopesByProvider = new Map>(); +const EMPTY_LIVE_MAX_MODE_EVIDENCE: ReadonlySet = new Set(); + +/** Register a credential scope under its provider so provider-scoped clears reach every record kind. */ +function registerLiveCursorRosterScope(scope: { provider: string; key: string }): void { + const scopes = liveCursorRosterScopesByProvider.get(scope.provider) ?? new Set(); + scopes.add(scope.key); + liveCursorRosterScopesByProvider.set(scope.provider, scopes); +} + +/** Provider names that currently hold credential-scoped roster state. */ +export function liveCursorRosterScopedProviders(): ReadonlySet { + return new Set(liveCursorRosterScopesByProvider.keys()); +} + +/** Non-secret key binding live roster evidence to one upstream destination and credential. */ +export function cursorLiveRosterScope(baseUrl: string | undefined, credential: string): string { + const destination = (baseUrl?.trim().replace(/\/+$/, "") || "https://api2.cursor.sh"); + return createHash("sha256") + .update("ocx:cursor:live-roster\0") + .update(destination) + .update("\0") + .update(credential) + .digest("hex"); +} -export function recordLiveCursorClaudeModels(liveIds: readonly string[]): void { +export function recordLiveCursorClaudeModels(liveIds: readonly string[], scope?: { provider: string; key: string }): void { const next = new Map(); for (const rawId of liveIds) { const n = normalizeCursorClaudeId(rawId.startsWith("cursor-") ? rawId.slice(7) : rawId); if (!n || !CURSOR_CAPABILITIES[n.canonicalBaseId]) continue; if (!next.has(n.canonicalBaseId)) next.set(n.canonicalBaseId, { sourceBaseId: n.sourceBaseId, spelling: n.spelling }); } - liveCursorClaudeWireIdentities = next; + if (scope) { + liveCursorClaudeWireIdentitiesByScope.set(scope.key, next); + registerLiveCursorRosterScope(scope); + } else { + liveCursorClaudeWireIdentities = next; + } } export function liveCursorClaudeWireIdentitiesForTests(): ReadonlyMap { @@ -656,15 +699,37 @@ export function liveCursorClaudeWireIdentitiesForTests(): ReadonlyMap(); for (const id of liveIds) { const parsed = parseCursorVariantId(id); if (parsed.known) bases.add(parsed.baseId); } - liveCursorMaxModeBases = bases; + if (scope) { + liveCursorMaxModeBasesByScope.set(scope.key, bases); + registerLiveCursorRosterScope(scope); + } else { + liveCursorMaxModeBases = bases; + } +} + +export function clearLiveCursorRosterState(provider?: string): void { + if (!provider) { + liveCursorClaudeWireIdentitiesByScope.clear(); + liveCursorMaxModeBasesByScope.clear(); + liveCursorRosterScopesByProvider.clear(); + return; + } + for (const scope of liveCursorRosterScopesByProvider.get(provider) ?? []) { + liveCursorClaudeWireIdentitiesByScope.delete(scope); + liveCursorMaxModeBasesByScope.delete(scope); + } + liveCursorRosterScopesByProvider.delete(provider); } export function liveCursorMaxModeBasesForTests(): ReadonlySet { diff --git a/src/adapters/cursor/request-builder.ts b/src/adapters/cursor/request-builder.ts index 065f971afa3..5eda825a61b 100644 --- a/src/adapters/cursor/request-builder.ts +++ b/src/adapters/cursor/request-builder.ts @@ -213,7 +213,7 @@ export function cursorRequestEmitsFastVariant(parsed: OcxParsedRequest): boolean * instead: current Cursor clients send the matching Grok base id plus `effort` and `fast` parameters. * A fully-qualified id (one that is not a known effort base) passes through unchanged. */ -function normalizeCursorModelId(modelId: string, reasoning?: string, fast?: boolean): { +function normalizeCursorModelId(modelId: string, reasoning?: string, fast?: boolean, liveRosterScope?: string): { modelId: string; requestedModelParameters?: readonly CursorRequestedModelParameter[]; routingLevel?: CursorRoutingLevel; @@ -239,7 +239,7 @@ function normalizeCursorModelId(modelId: string, reasoning?: string, fast?: bool ], }; } - const resolved = resolveCursorSelection(id, reasoning, undefined, { fast }); + const resolved = resolveCursorSelection(id, reasoning, undefined, { fast, liveRosterScope }); return { ...selection, ...(resolved.maxMode ? { maxMode: true } : {}), @@ -407,6 +407,8 @@ export function cursorCoveredPrefixDigest(parsed: OcxParsedRequest, coveredMessa export interface CreateCursorRequestOptions { /** Force a brand-new Cursor conversation id even when remembered state exists. */ forceFreshConversation?: boolean; + /** Credential-bound scope for live Cursor model spelling and Max-Mode evidence. */ + liveRosterScope?: string; } function lookupPrefixSnapshot( @@ -499,7 +501,12 @@ export function createCursorRequest( const visibleTools = cursorToolsForActivePrompt(parsed.context.tools, activeText, parsed.options.toolChoice); const budget = applyCursorToolBudget(visibleTools, parsed.options.toolChoice); const limitNote = catalogLimitNote(budget.tools, budget.omitted); - const model = normalizeCursorModelId(parsed.modelId, parsed.options.reasoning, cursorFastRequested(parsed)); + const model = normalizeCursorModelId( + parsed.modelId, + parsed.options.reasoning, + cursorFastRequested(parsed), + options.liveRosterScope, + ); const request: CursorRunRequest = { modelId: model.modelId, ...(model.requestedModelParameters ? { requestedModelParameters: model.requestedModelParameters } : {}), diff --git a/src/codex/catalog/provider-models.ts b/src/codex/catalog/provider-models.ts index d2641eba78d..2dcfcfd2318 100644 --- a/src/codex/catalog/provider-models.ts +++ b/src/codex/catalog/provider-models.ts @@ -52,7 +52,7 @@ import { routedSlug, slugEquals, slugEquivalenceKey, slugsEquivalent } from "../ import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity"; import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery"; import { fetchCursorUsableModels } from "../../adapters/cursor/live-models"; -import { recordLiveCursorClaudeModels, recordLiveCursorMaxModeModels } from "../../adapters/cursor/catalog"; +import { cursorLiveRosterScope, recordLiveCursorClaudeModels, recordLiveCursorMaxModeModels } from "../../adapters/cursor/catalog"; import { fetchQoderModels } from "../../adapters/qoder/live-models"; import { resolveQoderProfile } from "../../adapters/qoder/profiles"; import { fetchDevinUsableModels } from "../../adapters/devin/live-models"; @@ -400,10 +400,11 @@ export async function fetchProviderModelsWithAuth( // Publish roster-derived state only for a discovery the cache accepted: a stale // in-flight capture (generation revoked by a credential/config change) must not // overwrite the spelling or Max-Mode evidence of the newer one. - recordLiveCursorClaudeModels(liveResult.models); + const liveRosterScope = { provider: name, key: cursorLiveRosterScope(prov.baseUrl, apiKey) }; + recordLiveCursorClaudeModels(liveResult.models, liveRosterScope); // Live Max-Mode evidence feeds the umbrella resolver's ultra gate // (devlog 260828_cursor_umbrella_catalog; union with static evidence). - recordLiveCursorMaxModeModels(liveResult.maxModeModels ?? []); + recordLiveCursorMaxModeModels(liveResult.maxModeModels ?? [], liveRosterScope); markProviderDiscoveryOk(name, liveResult.models.length); return observed(withConfiguredRetention(forCache, { warnDrops: true }), "authoritative"); } diff --git a/src/codex/model-cache.ts b/src/codex/model-cache.ts index c69d15abc9e..5904fe587cf 100644 --- a/src/codex/model-cache.ts +++ b/src/codex/model-cache.ts @@ -10,6 +10,7 @@ import type { CatalogModel } from "./catalog"; import type { GenerationContext } from "../lib/state-store-sweeper"; import { enforceAppOwnedMemoryBudget, type RetainedStoreSnapshot } from "../lib/app-owned-memory"; +import { clearLiveCursorRosterState, liveCursorRosterScopedProviders } from "../adapters/cursor/catalog"; /** Default freshness window. Matches Codex's own 5-min models cache so the two stay in step. */ export const DEFAULT_MODEL_CACHE_TTL_MS = 5 * 60 * 1000; @@ -240,6 +241,7 @@ export function clearModelCache( provider?: string, reason: ModelCacheClearReason = "authority", ): void { + clearLiveCursorRosterState(provider); const revokesInFlightDiscovery = reason === "authority"; if (provider) { if (revokesInFlightDiscovery) { @@ -279,6 +281,7 @@ export function reconcileModelCacheProviders( ...discoveryStatus.keys(), ...liveModelCounts.keys(), ...cache.keys(), + ...liveCursorRosterScopedProviders(), ]); let revokedRemovedProviderAuthority = false; for (const provider of trackedProviders) { @@ -294,6 +297,9 @@ export function reconcileModelCacheProviders( providerCacheGenerations.set(provider, (providerCacheGenerations.get(provider) ?? 0) + 1); providerCacheGenerations.delete(provider); deleteCachedProvider(provider); + // Credential-scoped live roster state (Claude spellings, Max-Mode evidence) is keyed by + // provider too; a configured-away provider must not keep it alive until process end. + clearLiveCursorRosterState(provider); failureAt.delete(provider); discoveryStatus.delete(provider); liveModelCounts.delete(provider); diff --git a/tests/providers/cursor/cursor-catalog.test.ts b/tests/providers/cursor/cursor-catalog.test.ts index 4d8a79e352f..d4add16a87f 100644 --- a/tests/providers/cursor/cursor-catalog.test.ts +++ b/tests/providers/cursor/cursor-catalog.test.ts @@ -1,12 +1,15 @@ import { describe, expect, test } from "bun:test"; import { CURSOR_CAPABILITIES, + cursorLiveRosterScope, cursorUmbrellaRows, parseCursorVariantId, recordLiveCursorClaudeModels, + recordLiveCursorMaxModeModels, resetLiveCursorClaudeWireIdentitiesForTests, resolveCursorSelection, } from "../../../src/adapters/cursor/catalog"; +import { clearModelCache, reconcileModelCacheProviders } from "../../../src/codex/model-cache"; import { cursorEffortSuffix, cursorModelHasEffortTiers, @@ -34,6 +37,9 @@ const LEGACY_EFFORT_IDS = [ const CODEX_EFFORTS = ["low", "medium", "high", "xhigh", "max", "ultra", undefined] as const; +/** Monotonic generations for reconcileModelCacheProviders calls in this file. */ +let rosterReconcileGeneration = Date.now(); + const EXISTING_CLAUDE_WIRE_SNAPSHOT = { "claude-opus-5@high": "claude-opus-5-thinking-high", "claude-opus-5-thinking-fast@max": "claude-opus-5-thinking-max-fast", @@ -211,6 +217,71 @@ describe("cursor umbrella catalog (devlog 260828_cursor_umbrella_catalog)", () = } }); + test("live roster spellings stay isolated by provider credential scope and clear with its cache", () => { + const trusted = cursorLiveRosterScope("https://trusted.cursor.test", "trusted-token"); + const untrusted = cursorLiveRosterScope("https://other.cursor.test", "other-token"); + try { + // The surviving scope records a spelling distinct from the normalized default + // ("claude-4.6-opus-high-thinking"), so the post-clear assertion proves its slot lived. + recordLiveCursorClaudeModels( + ["claude-opus-4-6-thinking-high"], + { provider: "cursor-trusted", key: trusted }, + ); + recordLiveCursorClaudeModels( + ["claude-4.6-opus-high-thinking"], + { provider: "cursor-other", key: untrusted }, + ); + + expect(resolveCursorSelection("claude-4.6-opus", "high", undefined, { liveRosterScope: trusted }).wireId) + .toBe("claude-opus-4-6-thinking-high"); + expect(resolveCursorSelection("claude-4.6-opus", "high", undefined, { liveRosterScope: untrusted }).wireId) + .toBe("claude-4.6-opus-high-thinking"); + + clearModelCache("cursor-other"); + expect(resolveCursorSelection("claude-4.6-opus", "high", undefined, { liveRosterScope: untrusted }).wireId) + .toBe("claude-4.6-opus-high-thinking"); + expect(resolveCursorSelection("claude-4.6-opus", "high", undefined, { liveRosterScope: trusted }).wireId) + .toBe("claude-opus-4-6-thinking-high"); + } finally { + resetLiveCursorClaudeWireIdentitiesForTests(); + } + }); + + test("live Max Mode evidence stays isolated between credential scopes", () => { + const trusted = cursorLiveRosterScope("https://trusted.cursor.test", "trusted-token"); + const untrusted = cursorLiveRosterScope("https://other.cursor.test", "other-token"); + try { + recordLiveCursorMaxModeModels( + ["claude-opus-4-8-high"], + { provider: "cursor-trusted", key: trusted }, + ); + // Only the recording account's scope arms ultra; a scope with no record gets an + // empty evidence set — never the other account's slot. + expect(resolveCursorSelection("claude-opus-4-8", "ultra", undefined, { liveRosterScope: trusted }).maxMode) + .toBe(true); + expect(resolveCursorSelection("claude-opus-4-8", "ultra", undefined, { liveRosterScope: untrusted }).maxMode) + .toBe(false); + + // The process-global slot feeds unscoped calls only; it must not leak into a scoped request. + recordLiveCursorMaxModeModels(["claude-opus-4-8-high"]); + expect(resolveCursorSelection("claude-opus-4-8", "ultra").maxMode).toBe(true); + expect(resolveCursorSelection("claude-opus-4-8", "ultra", undefined, { liveRosterScope: untrusted }).maxMode) + .toBe(false); + + // Reconciliation keeps scoped evidence while the provider stays configured... + reconcileModelCacheProviders(new Set(["cursor-trusted"]), ++rosterReconcileGeneration); + expect(resolveCursorSelection("claude-opus-4-8", "ultra", undefined, { liveRosterScope: trusted }).maxMode) + .toBe(true); + // ...and drops it once the provider leaves the configuration. + reconcileModelCacheProviders(new Set(), ++rosterReconcileGeneration); + expect(resolveCursorSelection("claude-opus-4-8", "ultra", undefined, { liveRosterScope: trusted }).maxMode) + .toBe(false); + } finally { + recordLiveCursorMaxModeModels([]); + resetLiveCursorClaudeWireIdentitiesForTests(); + } + }); + test("ultra arms maxMode only on evidence-gated bases", () => { const kimi = resolveCursorSelection("kimi-k3-1m", "ultra"); expect(kimi.maxMode).toBe(true);