Skip to content
15 changes: 12 additions & 3 deletions src/adapters/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
58 changes: 51 additions & 7 deletions src/adapters/cursor/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -588,7 +589,7 @@ export function resolveCursorSelection(
pickedId: string,
reasoning: string | undefined,
liveMaxModeIds?: ReadonlySet<string>,
options: { fast?: boolean } = {},
options: { fast?: boolean; liveRosterScope?: string } = {},
): CursorResolvedSelection {
const parsed = parseCursorVariantId(pickedId);
if (!parsed.known) {
Expand All @@ -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);
Expand All @@ -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 };
}
Expand All @@ -628,15 +634,36 @@ export function resolveCursorSelection(
*/
let liveCursorMaxModeBases: ReadonlySet<string> = new Set();
let liveCursorClaudeWireIdentities: ReadonlyMap<string, CursorLiveClaudeWireIdentity> = new Map();
const liveCursorMaxModeBasesByScope = new Map<string, ReadonlySet<string>>();
const liveCursorClaudeWireIdentitiesByScope = new Map<string, ReadonlyMap<string, CursorLiveClaudeWireIdentity>>();
const liveCursorRosterScopesByProvider = new Map<string, Set<string>>();

/** 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve roster state across OAuth token refreshes

When Cursor OAuth refreshes its roughly hourly access token, hashing the raw token creates a new roster scope, but the model cache remains keyed only by provider name. A subsequent catalog gather can therefore return the still-fresh entry at provider-fetch.ts:1590-1595 without executing the new recording calls at provider-fetch.ts:1625-1629; requests using the refreshed token then find no scoped Claude spelling or Max-Mode evidence and may emit a rejected canonical model ID until another live discovery occurs. Invalidate the provider cache on token-generation changes, republish cached roster evidence into the new scope, or derive the scope from stable credential-lineage identity.

AGENTS.md reference: src/AGENTS.md:L20-L20

Useful? React with 👍 / 👎.

.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<string, CursorLiveClaudeWireIdentity>();
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<string>();
scopes.add(scope.key);
liveCursorRosterScopesByProvider.set(scope.provider, scopes);
} else {
liveCursorClaudeWireIdentities = next;
}
}

export function liveCursorClaudeWireIdentitiesForTests(): ReadonlyMap<string, CursorLiveClaudeWireIdentity> {
Expand All @@ -645,15 +672,32 @@ export function liveCursorClaudeWireIdentitiesForTests(): ReadonlyMap<string, Cu

export function resetLiveCursorClaudeWireIdentitiesForTests(): void {
liveCursorClaudeWireIdentities = new Map();
liveCursorClaudeWireIdentitiesByScope.clear();
liveCursorRosterScopesByProvider.clear();
}

export function recordLiveCursorMaxModeModels(liveIds: readonly string[]): void {
export function recordLiveCursorMaxModeModels(liveIds: readonly string[], scope?: { provider: string; key: string }): void {
const bases = new Set<string>();
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);
Comment on lines +697 to +698

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retain scopes still owned by another provider

If two configured Cursor provider names use the same destination and credential, both register the same scope key in their provider sets. Clearing either provider currently deletes that shared key unconditionally, so the other provider immediately loses its live wire spelling and Max-Mode evidence even though its own model cache and credential are unchanged. Track scope owners/reference counts, or include the provider identity in the stored key, so clearing one provider cannot invalidate another provider's state.

AGENTS.md reference: src/AGENTS.md:L20-L20

Useful? React with 👍 / 👎.

}
liveCursorRosterScopesByProvider.delete(provider);
}

export function liveCursorMaxModeBasesForTests(): ReadonlySet<string> {
Expand Down
13 changes: 10 additions & 3 deletions src/adapters/cursor/request-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 } : {}),
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 } : {}),
Expand Down
7 changes: 4 additions & 3 deletions src/codex/catalog/provider-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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");
}
Expand Down
2 changes: 2 additions & 0 deletions src/codex/model-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -201,6 +202,7 @@ export function clearModelCache(
provider?: string,
reason: ModelCacheClearReason = "authority",
): void {
clearLiveCursorRosterState(provider);
const revokesInFlightDiscovery = reason === "authority";
if (provider) {
if (revokesInFlightDiscovery) {
Expand Down
26 changes: 26 additions & 0 deletions tests/providers/cursor/cursor-catalog.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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);
Expand Down
Loading