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);