diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 60050d9b663..98f6c8563d9 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -315,6 +315,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `desktopExecutor?` | `DesktopExecutorConfig` | Cursor only: external computer-use and record-screen commands. | | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor legacy boolean, equivalent to `nativeLocalExec: "on"` only when the newer field is unset. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor local-exec policy. `off` is default; `codex-sandbox` currently fails closed like `off`. | +| `cursorAccountPool?` | `{ enabled: boolean }` | Cursor only. Off unless `enabled` is exactly `true`. Selects among two or more usable stored Cursor accounts for one credential identity scope and client thread. A single account, a missing client thread, or a malformed value keeps the configured credential. | Command Code's shipped per-model effort defaults include live API measurements. DeepSeek v4/v4.1 Flash (including v4 Flash Vision), GLM-5.3 and GLM-5.3-Flash, Qwen3.8-Flash, @@ -1312,6 +1313,12 @@ Set `nativeLocalExec` on `providers.cursor`, not at the top level. In the dashbo `nativeLocalExec: "on"` only when `nativeLocalExec` is unset. MCP, screen recording, and computer use are controlled separately by `mcpServers` and `desktopExecutor`. +`cursorAccountPool: { "enabled": true }` is also set on the Cursor provider. It stays off for any +other value. While on, a request with a client thread can send with another usable stored Cursor +account. The owner is the request's existing identity scope, or the hash of the configured +credential when that scope is absent. One stored account, or a request with no client thread, +leaves the configured credential in place. Conversation ids stay namespaced by that owner. + Foreground `shellArgs` and `shellStreamArgs` requests are rejected before process creation on Windows, macOS, and Linux, including with `nativeLocalExec: "on"`. They require a kernel-backed descendant owner that this implementation does not provide; use the client's shell tool instead. diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index d9aa283fbc7..ca9ea6cbda3 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -3,7 +3,7 @@ import type { AdapterEvent, OcxProviderConfig } from "../types"; import type { ProviderAdapter } from "./base"; import { isTranslatorBudgetExceededError } from "../lib/translator-budget"; import { cursorExecDeniedMessage, cursorRequestDeclaresFullAccess } from "./cursor/exec-policy"; -import { isCursorBenignCancelError, isCursorIncompleteToolCallMessage, isCursorInvalidArgumentError, isCursorOverflowRemintCandidate, isCursorRootEnvelopeError, safeCursorErrorMessage, type CursorSizeContext } from "./cursor/cursor-errors"; +import { classifyCursorError, isCursorBenignCancelError, isCursorIncompleteToolCallMessage, isCursorInvalidArgumentError, isCursorOverflowRemintCandidate, isCursorRootEnvelopeError, safeCursorErrorMessage, type CursorSizeContext } from "./cursor/cursor-errors"; import { cursorCheckpointModelAffinityId, cursorNeedsExternalToolContinuation, inferCursorContextWindow, isCursorExternalWireModel } from "./cursor/discovery"; import { createCursorKvStore, type CursorKvStore } from "./cursor/kv-store"; import { mapCursorServerMessage } from "./cursor/message-mapper"; @@ -66,6 +66,7 @@ import { } from "./cursor/transport"; import { cursorLiveRosterScope } from "./cursor/catalog"; import { cursorUsesPlainToolWording } from "./cursor/tool-wording"; +import { noteCursorPoolRateLimit, selectCursorPoolToken } from "../providers/cursor-pool"; export const CURSOR_API_URL = "https://api2.cursor.sh"; @@ -85,6 +86,8 @@ export interface CursorAdapterDeps { kv?: CursorKvStore; /** Test seam: observe/replace context-usage rekeying on conversation-id rotation. */ rekeyContextUsage?: (fromConversationId: string, toConversationId: string) => void; + /** Optional internal pool seam. Owner is supplied by trusted route parsing, never request headers. */ + selectPoolToken?: (owner: string, thread: string) => string | undefined; } function safeCursorTransportError(err: unknown, sizeContext?: CursorSizeContext): string { @@ -170,6 +173,10 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda // Captured after createCursorRequest so the catch block can apply the bare-RE // size prior (devlog 260) even though `request` is scoped inside the try. let requestSizeContext: CursorSizeContext | undefined; + let usingSharedPool = false; + let pooledToken: string | undefined; + let poolOwner = ""; + let poolThread = ""; try { const makeTransport = deps.createTransport ?? createLiveCursorTransport; const kv = deps.kv ?? createCursorKvStore({}, incoming.translatorBudget); @@ -177,6 +184,8 @@ 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. + // Opt-in pool selection uses that scope as the owner and does not replace it with the + // rotated account, so a pooled token cannot collapse two tenants onto one conversation. let liveRosterScope: string | undefined; if (!_parsed._cursorIdentityScope) { try { @@ -188,7 +197,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda .digest("hex") .slice(0, 16); } catch { - /* Missing credential is handled by the live transport path below. */ + // Missing credentials fail closed in the live transport path. } } else { try { @@ -197,6 +206,15 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda /* Missing credential is handled by the live transport path below. */ } } + usingSharedPool = provider.cursorAccountPool?.enabled === true && deps.selectPoolToken === undefined; + const poolTokenSelector = deps.selectPoolToken ?? (usingSharedPool ? selectCursorPoolToken : undefined); + poolOwner = _parsed._cursorIdentityScope ?? ""; + poolThread = _parsed._clientThreadId ?? ""; + pooledToken = poolTokenSelector?.(poolOwner, poolThread); + const activeProvider = pooledToken ? { ...provider, apiKey: pooledToken } : provider; + // Roster evidence follows the credential that will actually be sent. The identity + // scope above stays on the pre-pool credential so conversation isolation is unchanged. + if (pooledToken) liveRosterScope = cursorLiveRosterScope(provider.baseUrl, pooledToken); const inheritedCheckpointRef = _parsed._providerContinuation?.cursor?.checkpointRef; const previousConversationId = _parsed._cursorConversationId; let request = { @@ -392,7 +410,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda await runCursorTurnWithRetry( makeTransport, { - provider, + provider: activeProvider, headers: incoming.headers, translatorBudget: incoming.translatorBudget, requestDeclaresFullAccess: cursorRequestDeclaresFullAccess(activeRequest), @@ -710,6 +728,16 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda } } } catch (err) { + if ( + usingSharedPool + && pooledToken + && classifyCursorError( + err instanceof Error ? err.message : typeof err === "string" ? err : "", + requestSizeContext, + ) === "Cursor rate limit exceeded" + ) { + noteCursorPoolRateLimit(poolOwner, poolThread); + } if (isCursorBenignCancelError(err)) return; const partialUsage = (err as { partialUsage?: import("../types").OcxUsage }).partialUsage; emit({ diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index d9e1d07226e..ed160bad8a5 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -411,10 +411,23 @@ export const providerConfigSchema = z.object({ xaiResponsesXSearch: z.boolean().optional(), xaiResponsesDefaultVersion: z.number().int().positive().optional().catch(undefined), zaiResponsesDefaultVersion: z.number().int().positive().optional().catch(undefined), + // Opt-in and off by default. A malformed value degrades to absent so a hand edit cannot + // take the process through invalid-config recovery, and it cannot enable the pool either: + // only an exact `{ enabled: true }` object activates selection. + cursorAccountPool: z.object({ + enabled: z.boolean(), + }).strict().optional().catch(undefined), }).passthrough().superRefine((provider, ctx) => { if (provider.projectContext !== undefined && provider.adapter !== "command-code") { ctx.addIssue({ code: "custom", path: ["projectContext"], message: "projectContext is supported only by the command-code adapter" }); } + if (provider.cursorAccountPool !== undefined && provider.adapter !== "cursor") { + ctx.addIssue({ + code: "custom", + path: ["cursorAccountPool"], + message: "cursorAccountPool is supported only by the Cursor adapter", + }); + } }); diff --git a/src/providers/cursor-pool.ts b/src/providers/cursor-pool.ts index a83c93d2160..c3352c7a039 100644 --- a/src/providers/cursor-pool.ts +++ b/src/providers/cursor-pool.ts @@ -1,72 +1,420 @@ -/** - * Weighted credential routing for Cursor accounts. - * - * Transfer from yelixir-dev/cursor-ai-proxy-bridge credentials.ts: - * weighted round-robin selection with per-credential auth-failure cooldown - * and one-retry failover on a different account before surfacing the error. - * - * OpenCodex already has JWT-based multi-account identification (src/oauth/cursor.ts) - * and Anthropic-specific 429 rotation; this module adds Cursor-aware weighted - * routing on top of those primitives. - */ +/** Cursor OAuth account-pool kernel. No configuration or HTTP surface lives here. */ +import { debugProviderDiagnostic } from "../lib/debug"; +import { getAccountSet } from "../oauth/store"; +export const CURSOR_POOL_KEY = "cursor"; +export const CURSOR_POOL_TTL_MS = 30 * 60_000; +export const CURSOR_POOL_COOLDOWN_MS = 300_000; export interface CursorCredential { readonly id: string; weight: number; } +export class NoAvailableCursorCredentialError extends Error { + constructor(message = "No available Cursor credentials") { + super(message); + } +} +interface State { + ref: string; + owner: string; + thread: string; + cooldownUntil: number; + touched: number; + rotated: boolean; +} +export interface CursorPoolPick { + readonly accountRef: string; + readonly token: string; + readonly generation: number; +} +export interface CursorPoolSnapshot { + readonly generation: number; + readonly owner: string; + readonly thread: string; + readonly refs: ReadonlyArray; + readonly previous: ReadonlyArray; + readonly previousAffinity?: string; +} +function unexpired(expires: number | undefined, now: number): boolean { + return expires === undefined || (Number.isFinite(expires) && expires > now); +} -interface CredentialState { - readonly credential: CursorCredential; - currentWeight: number; - disabledUntil: number; +function usable( + account: CursorPoolAccount, + now: number, +): boolean { + if (account.needsReauth === true) return false; + return Boolean(account.access) && unexpired(account.expires, now); } -export class NoAvailableCursorCredentialError extends Error { - constructor(message = "No available Cursor credentials") { super(message); } +export interface CursorPoolAccount { + readonly id: string; + readonly access?: string; + readonly refresh?: string; + readonly expires?: number; + readonly needsReauth?: boolean; +} +export interface CursorPoolKernelOptions { + readonly resolveAccessToken?: (accountId: string) => string | undefined; + readonly listAccounts?: () => ReadonlyArray; } -export class CursorCredentialRouter { - private states: CredentialState[] = []; - private readonly cooldownMs: number; +export class CursorPoolKernel { + private states = new Map(); + private affinity = new Map(); + private versions = new Map(); + private generation = 0; + private readonly resolveAccessToken?: ( + accountId: string, + ) => string | undefined; + private readonly listAccounts?: () => ReadonlyArray; + private readonly refs = new Map(); + constructor( + private readonly capability: symbol = Symbol("cursor-pool"), + private readonly now: () => number = Date.now, + options: CursorPoolKernelOptions = {}, + ) { + this.resolveAccessToken = options.resolveAccessToken; + this.listAccounts = options.listAccounts; + } + get currentGeneration(): number { + return this.generation; + } + private key(owner: string, thread: string): string { + return `${owner}\0${thread}`; + } + private version(key: string): number { + return this.versions.get(key) ?? 0; + } + private advanceVersion(key: string): number { + const next = ++this.generation; + this.versions.set(key, next); + return next; + } + private liveStateKeys(): Set { + const live = new Set(); + for (const s of this.states.values()) { + live.add(this.key(s.owner, s.thread)); + } + return live; + } + private sweep(now = this.now()): void { + const candidates = new Set(); + for (const [key, s] of this.states) + if (s.touched + CURSOR_POOL_TTL_MS <= now) { + this.states.delete(key); + candidates.add(this.key(s.owner, s.thread)); + } + if (candidates.size) { + const live = this.liveStateKeys(); + for (const ownerThread of candidates) + if (!live.has(ownerThread)) { + this.affinity.delete(ownerThread); + this.versions.delete(ownerThread); + } + } + } + private rawAccounts(): ReadonlyArray { + return ( + this.listAccounts?.() ?? + (getAccountSet(CURSOR_POOL_KEY)?.accounts ?? []).map((a) => ({ + id: a.id, + ...a.credential, + needsReauth: a.needsReauth, + })) + ); + } + private accounts( + source: ReadonlyArray, + now: number, + ): Array<{ ref: string; id: string; token: string }> { + return source + .filter((a) => usable(a, now)) + .map((a) => { + let ref = this.refs.get(a.id); + if (!ref) { + ref = `cp_${crypto.randomUUID().replaceAll("-", "")}`; + this.refs.set(a.id, ref); + } + const token = + this.resolveAccessToken?.(a.id) ?? + (a.access && unexpired(a.expires, now) + ? a.access + : undefined); + return { ref, id: a.id, token: token ?? "" }; + }) + .filter((a) => Boolean(a.token)); + } + activate( + owner: string, + thread: string, + capability: symbol, + expectedGeneration?: number, + ): CursorPoolSnapshot | null { + const ownerThread = this.key(owner, thread); + if ( + capability !== this.capability || + !owner || + !thread || + (expectedGeneration !== undefined && + expectedGeneration !== this.version(ownerThread)) + ) + return null; + const { snapshot } = this.activateInternal(owner, thread); + return snapshot; + } + private activateInternal( + owner: string, + thread: string, + ): { + snapshot: CursorPoolSnapshot | null; + resolvedAccounts: Array<{ ref: string; id: string; token: string }>; + } { + const ownerThread = this.key(owner, thread); + this.sweep(); + const now = this.now(); + const raw = this.rawAccounts(); + const accounts = this.accounts(raw, now); + const knownSource = new Set(raw.map((a) => a.id)); + for (const [id, ref] of this.refs) + if (!knownSource.has(id)) this.removeRefState(ref); + if (accounts.length < 2) return { snapshot: null, resolvedAccounts: [] }; + const previous = accounts.flatMap((a) => { + const prior = this.states.get(`${owner}\0${thread}\0${a.ref}`); + return prior ? [{ ...prior }] : []; + }); + const previousAffinity = this.affinity.get(ownerThread); + for (const a of accounts) { + const key = `${owner}\0${thread}\0${a.ref}`; + const p = this.states.get(key); + const expired = p && p.cooldownUntil <= now; + this.states.set(key, { + ref: a.ref, + owner, + thread, + cooldownUntil: p?.cooldownUntil ?? 0, + rotated: expired ? false : (p?.rotated ?? false), + touched: now, + }); + } + const generation = this.advanceVersion(ownerThread); + return { + snapshot: { + generation, + owner, + thread, + refs: accounts.map((a) => a.ref), + previous, + previousAffinity, + }, + resolvedAccounts: accounts, + }; + } + pick( + owner: string, + thread: string, + capability: symbol, + ): CursorPoolPick | null { + if (capability !== this.capability || !owner || !thread) return null; + const { snapshot: snap, resolvedAccounts } = this.activateInternal( + owner, + thread, + ); + if (!snap) return null; + const now = this.now(), + key = this.key(owner, thread), + bound = this.affinity.get(key); + const candidates = snap.refs + .map((r) => this.states.get(`${owner}\0${thread}\0${r}`)!) + .filter((s) => s && s.cooldownUntil <= now); + const state = + (bound && candidates.find((s) => s.ref === bound)) || candidates[0]; + if (!state) return null; + this.affinity.set(key, state.ref); + state.touched = now; + const a = resolvedAccounts.find((x) => x.ref === state.ref); + return a + ? { accountRef: state.ref, token: a.token, generation: snap.generation } + : null; + } + noteAffinity429(owner: string, thread: string, capability: symbol, now = this.now()): boolean { + if (capability !== this.capability || !owner || !thread) return false; + const ref = this.affinity.get(this.key(owner, thread)); + if (!ref) return false; + return this.note429(ref, owner, thread, capability, now); + } + note429( + accountRef: string, + owner: string, + thread: string, + capability: symbol, + now = this.now(), + ): boolean { + if (capability !== this.capability) return false; + const s = this.states.get(`${owner}\0${thread}\0${accountRef}`); + if (!s) return false; + if (s.rotated && s.cooldownUntil > now) return false; + s.cooldownUntil = Math.max(s.cooldownUntil, now + CURSOR_POOL_COOLDOWN_MS); + s.rotated = true; + s.touched = now; + return true; + } + rollback(snapshot: CursorPoolSnapshot, capability: symbol): boolean { + const key = this.key(snapshot.owner, snapshot.thread); + if ( + capability !== this.capability || + snapshot.generation !== this.version(key) + ) + return false; + for (const [k, s] of this.states) + if (s.owner === snapshot.owner && s.thread === snapshot.thread) + this.states.delete(k); + for (const prior of snapshot.previous) + this.states.set(`${prior.owner}\0${prior.thread}\0${prior.ref}`, { ...prior }); + if (snapshot.previousAffinity) this.affinity.set(key, snapshot.previousAffinity); + else this.affinity.delete(key); + this.advanceVersion(key); + if (!snapshot.previous.length) this.versions.delete(key); + return true; + } + remove(accountRef: string, capability: symbol): void { + if (capability !== this.capability) return; + this.removeRefState(accountRef); + } + private removeRefState(accountRef: string): void { + const changed = new Set(); + for (const [k, s] of this.states) + if (s.ref === accountRef) { + this.states.delete(k); + changed.add(this.key(s.owner, s.thread)); + } + for (const [k, v] of this.affinity) + if (v === accountRef) this.affinity.delete(k); + let removedKnownRef = false; + for (const [id, ref] of this.refs) + if (ref === accountRef) { + this.refs.delete(id); + removedKnownRef = true; + } + // Membership is pool-global. A swept state no longer identifies every + // snapshot that observed this ref, so conservatively invalidate all live + // snapshot versions when a known account leaves the pool. + if (removedKnownRef) + for (const key of this.versions.keys()) changed.add(key); + const live = changed.size ? this.liveStateKeys() : undefined; + for (const key of changed) { + this.advanceVersion(key); + if (!live!.has(key)) this.versions.delete(key); + } + } + clear(capability: symbol): void { + if (capability === this.capability) { + this.versions.clear(); + this.states.clear(); + this.affinity.clear(); + this.refs.clear(); + } + } +} +export function createCursorPoolCapability(): symbol { + return Symbol(`cursor-pool:${crypto.randomUUID()}`); +} - constructor(credentials: ReadonlyArray, cooldownMs = 300_000) { - this.cooldownMs = cooldownMs; - this.replace(credentials); +const sharedCursorPoolCapability = createCursorPoolCapability(); +let sharedCursorPoolKernel: CursorPoolKernel | undefined; + +function sharedKernel(): CursorPoolKernel { + sharedCursorPoolKernel ??= new CursorPoolKernel(sharedCursorPoolCapability); + return sharedCursorPoolKernel; +} + +/** Test seam. Drops the process-local pool so the next pick builds a fresh one. */ +export function resetSharedCursorPoolKernelForTests(): void { + sharedCursorPoolKernel = undefined; +} + +/** + * Install the process-local pool with the same capability the adapter uses. + * The capability itself stays inside this module. + */ +export function installSharedCursorPoolKernelForTests( + now: () => number, + options: CursorPoolKernelOptions = {}, +): void { + sharedCursorPoolKernel = new CursorPoolKernel(sharedCursorPoolCapability, now, options); +} + +/** + * Bounded activation entry. Empty owner or thread fails closed. Fewer than two usable + * accounts yields undefined so the caller keeps its configured credential. + */ +export function selectCursorPoolToken(owner: string, thread: string): string | undefined { + if (!owner || !thread) return undefined; + try { + return sharedKernel().pick(owner, thread, sharedCursorPoolCapability)?.token; + } catch { + // A store failure must not replace a configured credential with a request error. + debugProviderDiagnostic("cursor", "pool-selection-failed", {}); + return undefined; + } +} + +/** Cool the account this owner/thread is bound to after that account rate-limits a send. */ +export function noteCursorPoolRateLimit(owner: string, thread: string): boolean { + if (!owner || !thread) return false; + try { + return sharedKernel().noteAffinity429(owner, thread, sharedCursorPoolCapability); + } catch { + debugProviderDiagnostic("cursor", "pool-cooldown-failed", {}); + return false; } +} +/** Legacy weighted router; generic 429 rotation is owned elsewhere. */ +export class CursorCredentialRouter { + private states: Array<{ + credential: CursorCredential; + currentWeight: number; + disabledUntil: number; + }> = []; + constructor( + credentials: ReadonlyArray, + private readonly cooldownMs = CURSOR_POOL_COOLDOWN_MS, + ) { + this.replace(credentials); + } replace(credentials: ReadonlyArray): void { - this.states = credentials.map(c => ({ + this.states = credentials.map((c) => ({ credential: { ...c, weight: Math.max(1, c.weight || 1) }, currentWeight: 0, disabledUntil: 0, })); } - pick(excludeIds: ReadonlySet = new Set()): CursorCredential { - const now = Date.now(); - const candidates = this.states.filter(s => - !excludeIds.has(s.credential.id) && s.disabledUntil <= now, - ); - if (candidates.length === 0) throw new NoAvailableCursorCredentialError(); - let selected: CredentialState | undefined; - let totalWeight = 0; - for (const state of candidates) { - state.currentWeight += state.credential.weight; - totalWeight += state.credential.weight; - if (!selected || state.currentWeight > selected.currentWeight) selected = state; + const now = Date.now(), + cs = this.states.filter( + (s) => !excludeIds.has(s.credential.id) && s.disabledUntil <= now, + ); + if (!cs.length) throw new NoAvailableCursorCredentialError(); + let selected = cs[0]!, + total = 0; + for (const s of cs) { + s.currentWeight += s.credential.weight; + total += s.credential.weight; + if (s.currentWeight > selected.currentWeight) selected = s; } - if (!selected) throw new NoAvailableCursorCredentialError(); - selected.currentWeight -= totalWeight; + selected.currentWeight -= total; return { ...selected.credential }; } - disable(id: string): void { - const state = this.states.find(s => s.credential.id === id); - if (state) state.disabledUntil = Date.now() + this.cooldownMs; + const s = this.states.find((x) => x.credential.id === id); + if (s) s.disabledUntil = Date.now() + this.cooldownMs; } - get snapshot(): ReadonlyArray<{ id: string; disabled: boolean }> { const now = Date.now(); - return this.states.map(s => ({ id: s.credential.id, disabled: s.disabledUntil > now })); + return this.states.map((s) => ({ + id: s.credential.id, + disabled: s.disabledUntil > now, + })); } } diff --git a/src/providers/model-rename-fields.ts b/src/providers/model-rename-fields.ts index 169ed920a4d..6879e4786c6 100644 --- a/src/providers/model-rename-fields.ts +++ b/src/providers/model-rename-fields.ts @@ -144,6 +144,7 @@ export const PROVIDER_MODEL_RENAME_ROLES = { project: "none", location: "none", mcpServers: "none", + cursorAccountPool: "none", desktopExecutor: "none", unsafeAllowNativeLocalExec: "none", nativeLocalExec: "none", diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 7363c49202d..846a5a26cf5 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -1169,6 +1169,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = { project: "editor", location: "editor", mcpServers: "redacted", + cursorAccountPool: "editor", desktopExecutor: "redacted", unsafeAllowNativeLocalExec: "editor", nativeLocalExec: "editor", diff --git a/src/types/provider.ts b/src/types/provider.ts index 7c310535b92..427c43f9775 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -1081,6 +1081,14 @@ export interface OcxProviderConfig { project?: string; /** Vertex AI location, e.g. "us-central1" or "global" (or GOOGLE_CLOUD_LOCATION env). */ location?: string; + /** + * Cursor adapter only. Opt-in OAuth account pool. Absent or any value other than + * `enabled: true` leaves the configured credential in place. A true value selects among + * two or more usable stored accounts inside the request's credential identity scope. + */ + cursorAccountPool?: { + enabled: boolean; + }; /** * Cursor adapter only: MCP servers opencodex starts/connects and exposes to the Cursor agent * as callable tools. Each entry is spawned (stdio `command`) or connected (`url`) lazily per diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index 1841faa872d..40ec5f0c67b 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -417,3 +417,22 @@ because a later deadline still produces the same message. The firing half needs on real timers in the same file: silence after the first frame, the progress budget alone failing a turn when the silence budget is out of reach, and `turnEnded` cancelling the watchdog while the server holds the stream open. + +## Opt-in account pool + +`providers..cursorAccountPool` is off unless it is exactly `{ "enabled": true }` on a +provider whose adapter is `cursor`. A malformed value is dropped at load and does not enable +selection. `src/adapters/cursor.ts` keeps deriving `_cursorIdentityScope` from an already-set +auth scope, or from the hash of the configured credential when that scope is absent, before any +pool read. That scope is the pool owner. The client thread is the affinity key. Neither value +is taken from a request header. + +When the flag is on, `selectCursorPoolToken` in `src/providers/cursor-pool.ts` may replace the +outbound access token. The replacement is used for the live roster scope of that send. The +identity scope itself stays on the pre-pool credential, so two tenants do not share a +conversation because they rotated onto the same stored account. Selection returns no token +unless the owner and thread are both non-empty and at least two accounts are usable, and the +adapter then sends the configured credential. A rate-limit failure cools only that owner and +thread's bound account. Authentication and context-overflow failures do not. The capability +that mutates the pool is not exported. Injected `selectPoolToken` remains a test seam and does +not record cooldown on the shared pool. diff --git a/tests/providers/cursor/cursor-adapter.test.ts b/tests/providers/cursor/cursor-adapter.test.ts index d09076a03de..f776ff018af 100644 --- a/tests/providers/cursor/cursor-adapter.test.ts +++ b/tests/providers/cursor/cursor-adapter.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, spyOn, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { afterEach, describe, expect, spyOn, test } from "bun:test"; import { createCursorAdapter as createCursorAdapterProduction, cursorExecDeniedMessage, @@ -43,6 +44,10 @@ import { resetDebugSettingsForTests, setDebugSettings } from "../../../src/lib/d import { encodeCursorCallId, resetCursorCallIdProvenanceForTests } from "../../../src/adapters/cursor/call-id"; import { handleCursorNativeKv, resetCursorBlobStateForTests } from "../../../src/adapters/cursor/native-exec"; import { CURSOR_MISSING_TOOL_RESULT, encodeCursorRunRequest } from "../../../src/adapters/cursor/protobuf-request"; +import { + installSharedCursorPoolKernelForTests, + resetSharedCursorPoolKernelForTests, +} from "../../../src/providers/cursor-pool"; const createCursorAdapter = (...args: Parameters) => withTestTranslatorBudget(createCursorAdapterProduction(...args)); @@ -145,6 +150,232 @@ describe("Cursor adapter live transport", () => { expect(inputs[0]?.fetch).toBe(pacedFetch); }); + const makeTransportMock = (inputs: CursorTransportFactoryInput[]) => + (input: CursorTransportFactoryInput) => { + inputs.push(input); + return { + async *run() { + yield { type: "done" } satisfies CursorServerMessage; + }, + writeClient() {}, + }; + }; + + test("runTurn passes selected pooled token to activeProvider transport", async () => { + const selectedInputs: CursorTransportFactoryInput[] = []; + const selectorCalls: Array<[string, string]> = []; + const scopedRequest: OcxParsedRequest = { + ...parsed, + context: { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, + _cursorIdentityScope: "owner-scope", + _clientThreadId: "thread-scope", + }; + const selected = createCursorAdapter( + { ...provider, apiKey: "original-token" }, + { + createTransport: makeTransportMock(selectedInputs), + selectPoolToken(owner, thread) { + selectorCalls.push([owner, thread]); + return "pooled-token"; + }, + }, + ); + + await selected.runTurn?.( + scopedRequest, + { headers: new Headers() }, + () => {}, + ); + + expect(selectorCalls).toEqual([["owner-scope", "thread-scope"]]); + expect(selectedInputs[0]?.provider.apiKey).toBe("pooled-token"); + }); + + test("runTurn falls back to provider.apiKey when selectPoolToken returns undefined", async () => { + const fallbackInputs: CursorTransportFactoryInput[] = []; + const scopedRequest: OcxParsedRequest = { + ...parsed, + context: { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, + _cursorIdentityScope: "owner-scope", + _clientThreadId: "thread-scope", + }; + const fallback = createCursorAdapter( + { ...provider, apiKey: "original-token" }, + { + createTransport: makeTransportMock(fallbackInputs), + selectPoolToken: () => undefined, + }, + ); + await fallback.runTurn?.( + { ...scopedRequest }, + { headers: new Headers() }, + () => {}, + ); + expect(fallbackInputs[0]?.provider.apiKey).toBe("original-token"); + }); + + test("runTurn falls back to provider.apiKey when selectPoolToken is unconfigured", async () => { + const unconfiguredInputs: CursorTransportFactoryInput[] = []; + const scopedRequest: OcxParsedRequest = { + ...parsed, + context: { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, + _cursorIdentityScope: "owner-scope", + _clientThreadId: "thread-scope", + }; + const unconfigured = createCursorAdapter( + { ...provider, apiKey: "original-token" }, + { + createTransport: makeTransportMock(unconfiguredInputs), + }, + ); + await unconfigured.runTurn?.( + { ...scopedRequest }, + { headers: new Headers() }, + () => {}, + ); + expect(unconfiguredInputs[0]?.provider.apiKey).toBe("original-token"); + }); + + test("runTurn derives deterministic fallback identity scope when unscoped", async () => { + const fallbackScopeInputs: CursorTransportFactoryInput[] = []; + const fallbackScopeCalls: Array<[string, string]> = []; + const fallbackScopeAdapter = createCursorAdapter( + { ...provider, apiKey: "original-token" }, + { + createTransport: makeTransportMock(fallbackScopeInputs), + selectPoolToken(owner, thread) { + fallbackScopeCalls.push([owner, thread]); + return undefined; + }, + }, + ); + const unscopedRequest: OcxParsedRequest = { + ...parsed, + context: { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, + _clientThreadId: "thread-scope", + }; + await fallbackScopeAdapter.runTurn?.( + unscopedRequest, + { headers: new Headers() }, + () => {}, + ); + const expectedScope = createHash("sha256") + .update("ocx:cursor:acct:") + .update("original-token") + .digest("hex") + .slice(0, 16); + expect(fallbackScopeCalls).toHaveLength(1); + expect(fallbackScopeCalls[0]?.[0]).toBe(expectedScope); + expect(fallbackScopeCalls[0]?.[0]).toMatch(/^[0-9a-f]{16}$/); + expect(fallbackScopeCalls[0]?.[1]).toBe("thread-scope"); + expect(fallbackScopeInputs[0]?.provider.apiKey).toBe("original-token"); + + await fallbackScopeAdapter.runTurn?.( + unscopedRequest, + { headers: new Headers() }, + () => {}, + ); + expect(fallbackScopeCalls[1]?.[0]).toBe(expectedScope); + }); + + describe("bounded account-pool activation", () => { + const poolAccounts = [ + { id: "account-a", access: "access-a", expires: Number.MAX_SAFE_INTEGER }, + { id: "account-b", access: "access-b", expires: Number.MAX_SAFE_INTEGER }, + ]; + const scopedRequest: OcxParsedRequest = { + ...parsed, + context: { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, + _cursorIdentityScope: "owner-scope", + _clientThreadId: "thread-scope", + }; + + afterEach(() => { + resetSharedCursorPoolKernelForTests(); + }); + + test("stays on the configured credential unless the pool is exactly enabled", async () => { + installSharedCursorPoolKernelForTests(() => 1_000, { listAccounts: () => poolAccounts }); + const inputs: CursorTransportFactoryInput[] = []; + const adapter = createCursorAdapter( + { ...provider, apiKey: "original-token", cursorAccountPool: { enabled: false } }, + { createTransport: makeTransportMock(inputs) }, + ); + await adapter.runTurn?.(scopedRequest, { headers: new Headers() }, () => {}); + expect(inputs[0]?.provider.apiKey).toBe("original-token"); + }); + + test("uses the pooled credential and rotates only that owner after a rate limit", async () => { + installSharedCursorPoolKernelForTests(() => 1_000, { listAccounts: () => poolAccounts }); + const inputs: CursorTransportFactoryInput[] = []; + let calls = 0; + const adapter = createCursorAdapter( + { ...provider, apiKey: "original-token", cursorAccountPool: { enabled: true } }, + { + createTransport(input) { + inputs.push(input); + return { + async *run() { + calls += 1; + if (calls === 1) throw new Error("Cursor rate limit exceeded"); + yield { type: "done" } satisfies CursorServerMessage; + }, + writeClient() {}, + }; + }, + }, + ); + const events: AdapterEvent[] = []; + await adapter.runTurn?.(scopedRequest, { headers: new Headers() }, event => events.push(event)); + expect(events[0]).toMatchObject({ type: "error" }); + const cooled = inputs[0]?.provider.apiKey; + expect(["access-a", "access-b"]).toContain(cooled); + expect(cooled).not.toBe("original-token"); + + const otherOwner: CursorTransportFactoryInput[] = []; + const sibling = createCursorAdapter( + { ...provider, apiKey: "other-token", cursorAccountPool: { enabled: true } }, + { createTransport: makeTransportMock(otherOwner) }, + ); + await sibling.runTurn?.( + { ...scopedRequest, _cursorIdentityScope: "other-owner" }, + { headers: new Headers() }, + () => {}, + ); + expect(otherOwner[0]?.provider.apiKey).toBe(cooled); + + await adapter.runTurn?.(scopedRequest, { headers: new Headers() }, () => {}); + expect(inputs[1]?.provider.apiKey).not.toBe(cooled); + expect(["access-a", "access-b"]).toContain(inputs[1]?.provider.apiKey); + }); + + test("does not cool the pool when the failure is not a rate limit", async () => { + installSharedCursorPoolKernelForTests(() => 1_000, { listAccounts: () => poolAccounts }); + const inputs: CursorTransportFactoryInput[] = []; + let calls = 0; + const adapter = createCursorAdapter( + { ...provider, apiKey: "original-token", cursorAccountPool: { enabled: true } }, + { + createTransport(input) { + inputs.push(input); + return { + async *run() { + calls += 1; + if (calls === 1) throw new Error("Cursor authentication failed: invalid token"); + yield { type: "done" } satisfies CursorServerMessage; + }, + writeClient() {}, + }; + }, + }, + ); + await adapter.runTurn?.(scopedRequest, { headers: new Headers() }, () => {}); + const first = inputs[0]?.provider.apiKey; + await adapter.runTurn?.(scopedRequest, { headers: new Headers() }, () => {}); + expect(inputs[1]?.provider.apiKey).toBe(first); + }); + }); + // #1527: the envelope rejection is raised locally while building the request, so it surfaces // through the same terminal catch as a transport fault. Review found the class was flattened to // a bare message there, losing the stable code a caller needs to tell "this conversation cannot diff --git a/tests/providers/cursor/cursor-pool.test.ts b/tests/providers/cursor/cursor-pool.test.ts index a636b14e2aa..1943a8b799e 100644 --- a/tests/providers/cursor/cursor-pool.test.ts +++ b/tests/providers/cursor/cursor-pool.test.ts @@ -1,5 +1,17 @@ -import { describe, expect, test } from "bun:test"; -import { CursorCredentialRouter, NoAvailableCursorCredentialError } from "../../../src/providers/cursor-pool"; +import { afterEach, describe, expect, test } from "bun:test"; +import { providerConfigSchema } from "../../../src/config/schema/leaf-validators"; +import { + CursorCredentialRouter, + CursorPoolKernel, + NoAvailableCursorCredentialError, + createCursorPoolCapability, + CURSOR_POOL_COOLDOWN_MS, + CURSOR_POOL_TTL_MS, + installSharedCursorPoolKernelForTests, + noteCursorPoolRateLimit, + resetSharedCursorPoolKernelForTests, + selectCursorPoolToken, +} from "../../../src/providers/cursor-pool"; describe("CursorCredentialRouter", () => { test("weighted round-robin distributes picks proportionally", () => { @@ -32,3 +44,376 @@ describe("CursorCredentialRouter", () => { expect(cred.id).toBe("b"); }); }); +describe("CursorPoolKernel", () => { + const accounts = [ + { id: "account-a", access: "access-a", expires: Number.MAX_SAFE_INTEGER }, + { id: "account-b", access: "access-b", expires: Number.MAX_SAFE_INTEGER }, + ]; + function setup(now = 1_000, resolver?: (id: string) => string | undefined) { + let clock = now; + let listed = [...accounts]; + const capability = createCursorPoolCapability(); + const kernel = new CursorPoolKernel(capability, () => clock, { + listAccounts: () => listed, + resolveAccessToken: resolver, + }); + return { + kernel, + capability, + advance: (ms: number) => { + clock += ms; + }, + setAccounts: (next: typeof accounts) => { + listed = [...next]; + }, + }; + } + test("requires capability, trusted owner, and two usable accounts", () => { + const { kernel, capability, setAccounts } = setup(); + expect(kernel.pick("owner", "thread", Symbol("wrong"))).toBeNull(); + expect(kernel.pick("", "thread", capability)).toBeNull(); + setAccounts([accounts[0]!]); + expect(kernel.pick("owner", "thread", capability)).toBeNull(); + setAccounts(accounts); + const snapshot = kernel.activate("owner", "thread", capability); + expect(snapshot).not.toBeNull(); + const serialized = JSON.stringify(snapshot); + for (const secret of ["account-a", "account-b", "access-a", "access-b"]) + expect(serialized).not.toContain(secret); + }); + test("same thread text is isolated by owner and absent scope fails closed", () => { + const { kernel, capability } = setup(); + const a = kernel.pick("owner-a", "same", capability)!; + const b = kernel.pick("owner-b", "same", capability)!; + expect(a).not.toBeNull(); + expect(b).not.toBeNull(); + expect(kernel.note429(a.accountRef, "owner-a", "same", capability)).toBe(true); + expect(kernel.pick("owner-b", "same", capability)?.accountRef).toBe(b.accountRef); + expect(kernel.pick("", "same", capability)).toBeNull(); + }); + test("refs are random opaque values and token is never exposed by snapshot", () => { + const a = setup().kernel.pick("o", "t", setup().capability); + const first = setup(); + const picked = first.kernel.pick("o", "t", first.capability)!; + expect(picked.accountRef).toMatch(/^cp_[0-9a-f]{32}$/); + expect(picked.accountRef).not.toContain("account-a"); + expect(JSON.stringify(picked)).not.toContain("account-a"); + expect(a).toBeNull(); + }); + test("uses authoritative resolver and never falls back to refresh token", () => { + const { kernel, capability } = setup(1_000, (id) => + id === "account-a" ? "resolved-a" : "resolved-b", + ); + expect(kernel.pick("o", "t", capability)?.token).toBe("resolved-a"); + const expired = new CursorPoolKernel(capability, () => 3_000, { + listAccounts: () => + accounts.map((a) => ({ ...a, access: undefined, refresh: "refresh" })), + }); + expect(expired.pick("o", "t", capability)).toBeNull(); + }); + test("sticky generation and exactly-once monotonic cooldown", () => { + const s = setup(); + const first = s.kernel.pick("o", "t", s.capability)!; + const second = s.kernel.pick("o", "t", s.capability)!; + expect(second.accountRef).toBe(first.accountRef); + expect(second.generation).toBeGreaterThan(first.generation); + expect(s.kernel.note429(first.accountRef, "o", "t", s.capability)).toBe( + true, + ); + expect(s.kernel.note429(first.accountRef, "o", "t", s.capability)).toBe( + false, + ); + expect(s.kernel.note429(first.accountRef, "other", "t", s.capability)).toBe( + false, + ); + s.advance(CURSOR_POOL_COOLDOWN_MS + 1); + expect(s.kernel.note429(first.accountRef, "o", "t", s.capability)).toBe( + true, + ); + }); + test("rollback is owner-scoped CAS; TTL, removal and clear leave no state", () => { + const s = setup(); + const a = s.kernel.pick("a", "t", s.capability)!; + const b = s.kernel.pick("b", "t", s.capability)!; + const snap = s.kernel.activate("a", "t", s.capability)!; + s.kernel.pick("b", "other-thread", s.capability); + expect(s.kernel.rollback(snap, s.capability)).toBe(true); + expect(s.kernel.pick("b", "t", s.capability)?.accountRef).toBe( + b.accountRef, + ); + expect(s.kernel.rollback(snap, s.capability)).toBe(false); + const generationBeforeRemove = s.kernel.currentGeneration; + s.kernel.remove(b.accountRef, s.capability); + expect(s.kernel.currentGeneration).toBeGreaterThan(generationBeforeRemove); + expect(s.kernel.rollback(snap, s.capability)).toBe(false); + const reminted = s.kernel.pick("b", "t", s.capability); + expect(reminted).not.toBeNull(); + expect(reminted?.accountRef).not.toBe(b.accountRef); + s.advance(CURSOR_POOL_TTL_MS + 1); + const beforeClear = s.kernel.pick("a", "t2", s.capability)!; + const clearSnapshot = s.kernel.activate("a", "t2", s.capability)!; + s.kernel.clear(s.capability); + // Old pre-clear snapshot is rejected by rollback + expect(s.kernel.rollback(clearSnapshot, s.capability)).toBe(false); + // Recreated key mints a new ref and acquires a strictly later monotonic generation + const afterClear = s.kernel.pick("a", "t2", s.capability)!; + expect(afterClear.accountRef).not.toBe(beforeClear.accountRef); + expect(afterClear.generation).toBeGreaterThan(clearSnapshot.generation); + // And rollback with the old snapshot still fails against the recreated key + expect(s.kernel.rollback(clearSnapshot, s.capability)).toBe(false); + }); + + test("TTL sweep for one owner preserves another owner's live affinity", () => { + const s = setup(); + const ownerA = s.kernel.pick("owner-a", "thread", s.capability)!; + s.advance(CURSOR_POOL_TTL_MS - 1); + const ownerB = s.kernel.pick("owner-b", "thread", s.capability)!; + s.advance(2); + expect(s.kernel.pick("owner-b", "thread", s.capability)?.accountRef).toBe( + ownerB.accountRef, + ); + expect(ownerA).not.toBeNull(); + }); + + test("retains opaque refs for temporarily unusable accounts across activate", () => { + const s = setup(); + const pick1 = s.kernel.pick("owner-a", "thread", s.capability)!; + const originalRef = pick1.accountRef; + + s.setAccounts([ + { id: "account-a", access: "access-a", expires: 500, needsReauth: true }, + { id: "account-b", access: "access-b", expires: Number.MAX_SAFE_INTEGER }, + { id: "account-c", access: "access-c", expires: Number.MAX_SAFE_INTEGER }, + ]); + const snap = s.kernel.activate("owner-b", "thread", s.capability); + expect(snap).not.toBeNull(); + + s.setAccounts(accounts); + const pickRestored = s.kernel.pick("owner-a", "thread", s.capability)!; + expect(pickRestored.accountRef).toBe(originalRef); + }); + + test("prunes removed refs even when the usable pool falls below threshold", () => { + const s = setup(); + const original = s.kernel.pick("owner", "thread", s.capability)!; + + s.setAccounts([ + { id: "account-b", access: "access-b", expires: Number.MAX_SAFE_INTEGER }, + ]); + expect(s.kernel.activate("owner", "thread", s.capability)).toBeNull(); + + s.setAccounts(accounts); + const restored = s.kernel.pick("owner", "thread", s.capability)!; + expect(restored.accountRef).not.toBe(original.accountRef); + }); + + test("removing an account below activation threshold invalidates earlier snapshot rollback", () => { + const s = setup(); + const snap1 = s.kernel.activate("owner-a", "thread", s.capability)!; + expect(snap1).not.toBeNull(); + const snap2 = s.kernel.activate("owner-b", "thread", s.capability)!; + expect(snap2).not.toBeNull(); + + s.setAccounts([ + { id: "account-b", access: "access-b", expires: Number.MAX_SAFE_INTEGER }, + ]); + expect(s.kernel.activate("owner-a", "thread", s.capability)).toBeNull(); + expect(s.kernel.rollback(snap1, s.capability)).toBe(false); + expect(s.kernel.rollback(snap2, s.capability)).toBe(false); + expect( + s.kernel.note429( + snap1.refs[0]!, + "owner-a", + "thread", + s.capability, + ), + ).toBe(false); + }); + + test("removal invalidates a snapshot after only that account state was swept", () => { + const s = setup(); + const snap = s.kernel.activate("owner", "thread", s.capability)!; + + s.advance(CURSOR_POOL_TTL_MS - 1); + expect( + s.kernel.note429( + snap.refs[1]!, + "owner", + "thread", + s.capability, + ), + ).toBe(true); + s.advance(2); + s.setAccounts([ + { id: "account-b", access: "access-b", expires: Number.MAX_SAFE_INTEGER }, + ]); + + expect(s.kernel.activate("owner", "thread", s.capability)).toBeNull(); + expect(s.kernel.rollback(snap, s.capability)).toBe(false); + expect( + s.kernel.note429( + snap.refs[0]!, + "owner", + "thread", + s.capability, + ), + ).toBe(false); + }); + + test("rejects NaN expiry in usable and unexpired checks", () => { + const s = setup(); + s.setAccounts([ + { id: "account-a", access: "access-a", expires: NaN }, + { id: "account-b", access: "access-b", expires: Number.MAX_SAFE_INTEGER }, + ]); + expect(s.kernel.pick("owner", "thread", s.capability)).toBeNull(); + }); + + test("sweep prunes versions when owner/thread has no live states", () => { + const s = setup(); + s.kernel.pick("owner-ephemeral", "thread", s.capability); + s.advance(CURSOR_POOL_TTL_MS + 1); + s.kernel.pick("owner-other", "thread", s.capability); + const staleSnap = { + generation: 1, + owner: "owner-ephemeral", + thread: "thread", + refs: [], + previous: [], + }; + expect(s.kernel.rollback(staleSnap, s.capability)).toBe(false); + }); + + test("removing the final account prunes its version before key recreation", () => { + const s = setup(); + const stale = s.kernel.activate("owner", "thread", s.capability)!; + const generationBeforeRemoval = s.kernel.currentGeneration; + + s.setAccounts([]); + expect(s.kernel.activate("owner", "thread", s.capability)).toBeNull(); + expect(s.kernel.currentGeneration).toBeGreaterThan(generationBeforeRemoval); + expect(s.kernel.rollback(stale, s.capability)).toBe(false); + expect( + ( + s.kernel as unknown as { + versions: Map; + } + ).versions.size, + ).toBe(0); + + s.setAccounts(accounts); + const recreated = s.kernel.pick("owner", "thread", s.capability)!; + expect(recreated.generation).toBeGreaterThan(generationBeforeRemoval); + expect(s.kernel.rollback(stale, s.capability)).toBe(false); + }); + + test("rolling back an initial activation prunes its orphaned version", () => { + const s = setup(); + const initial = s.kernel.activate("owner", "thread", s.capability)!; + + expect(s.kernel.rollback(initial, s.capability)).toBe(true); + expect( + ( + s.kernel as unknown as { + versions: Map; + } + ).versions.size, + ).toBe(0); + expect(s.kernel.rollback(initial, s.capability)).toBe(false); + }); + + test("activate and pick perform exactly one listAccounts store read and resolve pass per call", () => { + let listAccountsCalls = 0; + let resolveCalls = 0; + const capability = createCursorPoolCapability(); + const kernel = new CursorPoolKernel(capability, () => 1_000, { + listAccounts: () => { + listAccountsCalls++; + return accounts; + }, + resolveAccessToken: (id) => { + resolveCalls++; + return id === "account-a" ? "access-a" : "access-b"; + }, + }); + + const picked = kernel.pick("owner", "thread", capability); + expect(picked).not.toBeNull(); + // Exactly 1 listAccounts call and 1 resolve pass per account (2 accounts) + expect(listAccountsCalls).toBe(1); + expect(resolveCalls).toBe(2); + + const activated = kernel.activate("owner", "thread-2", capability); + expect(activated).not.toBeNull(); + expect(listAccountsCalls).toBe(2); + expect(resolveCalls).toBe(4); + }); +}); + +describe("Cursor pool bounded activation", () => { + const accounts = [ + { id: "account-a", access: "access-a", expires: Number.MAX_SAFE_INTEGER }, + { id: "account-b", access: "access-b", expires: Number.MAX_SAFE_INTEGER }, + ]; + + afterEach(() => { + resetSharedCursorPoolKernelForTests(); + }); + + test("refuses an empty owner or thread and a single usable account", () => { + installSharedCursorPoolKernelForTests(() => 1_000, { listAccounts: () => accounts }); + expect(selectCursorPoolToken("", "thread")).toBeUndefined(); + expect(selectCursorPoolToken("owner", "")).toBeUndefined(); + expect(noteCursorPoolRateLimit("", "thread")).toBe(false); + resetSharedCursorPoolKernelForTests(); + installSharedCursorPoolKernelForTests(() => 1_000, { + listAccounts: () => [accounts[0]!], + }); + expect(selectCursorPoolToken("owner", "thread")).toBeUndefined(); + }); + + test("a pool failure leaves selection and cooldown unset", () => { + installSharedCursorPoolKernelForTests(() => { + throw new Error("clock unavailable"); + }, { listAccounts: () => accounts }); + expect(selectCursorPoolToken("owner", "thread")).toBeUndefined(); + expect(noteCursorPoolRateLimit("owner", "thread")).toBe(false); + }); + + test("keeps thread affinity and cools only that owner after a rate limit", () => { + installSharedCursorPoolKernelForTests(() => 1_000, { listAccounts: () => accounts }); + const first = selectCursorPoolToken("owner-a", "thread"); + const sibling = selectCursorPoolToken("owner-b", "thread"); + expect(first).toBe(sibling); + expect(["access-a", "access-b"]).toContain(first); + expect(noteCursorPoolRateLimit("owner-a", "thread")).toBe(true); + expect(selectCursorPoolToken("owner-a", "thread")).not.toBe(first); + expect(selectCursorPoolToken("owner-b", "thread")).toBe(sibling); + expect(noteCursorPoolRateLimit("owner-a", "absent")).toBe(false); + }); + + test("accepts only an exact Cursor pool opt-in", () => { + const cursor = { adapter: "cursor", baseUrl: "https://api2.cursor.sh" }; + expect(providerConfigSchema.parse({ + ...cursor, + cursorAccountPool: { enabled: true }, + }).cursorAccountPool).toEqual({ enabled: true }); + expect(providerConfigSchema.parse({ + ...cursor, + cursorAccountPool: { enabled: false }, + }).cursorAccountPool).toEqual({ enabled: false }); + expect(providerConfigSchema.parse({ + ...cursor, + cursorAccountPool: { enabled: "yes" }, + }).cursorAccountPool).toBeUndefined(); + expect(providerConfigSchema.parse({ + ...cursor, + cursorAccountPool: { enabled: true, strategy: "round-robin" }, + }).cursorAccountPool).toBeUndefined(); + expect(() => providerConfigSchema.parse({ + adapter: "openai", + baseUrl: "https://api.openai.com/v1", + cursorAccountPool: { enabled: true }, + })).toThrow(/cursorAccountPool is supported only by the Cursor adapter/); + }); +});