diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index 157da1eaa3..dbac696114 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -46,6 +46,7 @@ import { CursorTransportDisabledError, type CursorTransportFactory, } from "./cursor/transport"; +import { cursorLiveRosterScope } from "./cursor/catalog"; export const CURSOR_API_URL = "https://api2.cursor.sh"; @@ -148,9 +149,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) @@ -159,10 +162,16 @@ 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); + let request = createCursorRequest(_parsed, { liveRosterScope }); requestSizeContext = cursorRequestSizeContext(request); // The builder may derive a stable provider id from the client thread when Responses state // is unavailable. Rekey only existing state; there is nothing to migrate on a fresh turn, @@ -425,7 +434,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda lastTransport = undefined; _parsed._cursorConversationId = undefined; request = { - ...createCursorRequest(_parsed, { forceFreshConversation: true }), + ...createCursorRequest(_parsed, { forceFreshConversation: true, liveRosterScope }), echoRetryContinuationText: outputGuardRetryText, }; rekeyContextUsage(echoedConversationId, request.conversationId); @@ -456,7 +465,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda const failedConversationId = request.conversationId; lastTransport = undefined; _parsed._cursorConversationId = undefined; - request = createCursorRequest(_parsed, { forceFreshConversation: true }); + request = createCursorRequest(_parsed, { forceFreshConversation: true, liveRosterScope }); rekeyContextUsage(failedConversationId, request.conversationId); _parsed._cursorConversationId = request.conversationId; // Persist recovery for store:false clients that send any stable Cursor thread owner, so diff --git a/src/adapters/cursor/catalog.ts b/src/adapters/cursor/catalog.ts index f32249f051..ab016e1979 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 @@ -588,7 +589,7 @@ 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) { @@ -606,7 +607,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); @@ -615,7 +619,9 @@ 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) : undefined) + ?? liveCursorMaxModeBases; const maxModeArmed = capability.maxModeVerified === true || evidence.has(parsed.baseId); return { wireId, canonicalId, maxMode: ultraRequested && maxModeArmed, known: true }; } @@ -628,15 +634,36 @@ 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>(); + +/** 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); + const scopes = liveCursorRosterScopesByProvider.get(scope.provider) ?? new Set(); + scopes.add(scope.key); + liveCursorRosterScopesByProvider.set(scope.provider, scopes); + } else { + liveCursorClaudeWireIdentities = next; + } } export function liveCursorClaudeWireIdentitiesForTests(): ReadonlyMap { @@ -645,15 +672,32 @@ 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); + 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 51651bb07a..68f7d5d745 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 } : {}), @@ -396,6 +396,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( @@ -482,7 +484,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-fetch.ts b/src/codex/catalog/provider-fetch.ts index 58e21cf82c..c05e6eba23 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -51,7 +51,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 { isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; import { COMBO_NAMESPACE, @@ -1622,10 +1622,11 @@ 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 067c4195ca..8df1e13c27 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 } 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; @@ -201,6 +202,7 @@ export function clearModelCache( provider?: string, reason: ModelCacheClearReason = "authority", ): void { + clearLiveCursorRosterState(provider); const revokesInFlightDiscovery = reason === "authority"; if (provider) { if (revokesInFlightDiscovery) { diff --git a/tests/providers/cursor/cursor-catalog.test.ts b/tests/providers/cursor/cursor-catalog.test.ts index 4d8a79e352..f4b1580ad7 100644 --- a/tests/providers/cursor/cursor-catalog.test.ts +++ b/tests/providers/cursor/cursor-catalog.test.ts @@ -1,12 +1,14 @@ import { describe, expect, test } from "bun:test"; import { CURSOR_CAPABILITIES, + cursorLiveRosterScope, cursorUmbrellaRows, parseCursorVariantId, recordLiveCursorClaudeModels, resetLiveCursorClaudeWireIdentitiesForTests, resolveCursorSelection, } from "../../../src/adapters/cursor/catalog"; +import { clearModelCache } from "../../../src/codex/model-cache"; import { cursorEffortSuffix, cursorModelHasEffortTiers, @@ -211,6 +213,30 @@ 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"); + recordLiveCursorClaudeModels( + ["claude-4.6-opus-high-thinking"], + { provider: "cursor-trusted", key: trusted }, + ); + recordLiveCursorClaudeModels( + ["claude-opus-4-6-thinking-high"], + { provider: "cursor-other", key: untrusted }, + ); + + expect(resolveCursorSelection("claude-4.6-opus", "high", undefined, { liveRosterScope: trusted }).wireId) + .toBe("claude-4.6-opus-high-thinking"); + expect(resolveCursorSelection("claude-4.6-opus", "high", undefined, { liveRosterScope: untrusted }).wireId) + .toBe("claude-opus-4-6-thinking-high"); + + 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-4.6-opus-high-thinking"); + }); + test("ultra arms maxMode only on evidence-gated bases", () => { const kimi = resolveCursorSelection("kimi-k3-1m", "ultra"); expect(kimi.maxMode).toBe(true);