From fa0cd5af1c9beec02fe0cf8eb964eb6757184e08 Mon Sep 17 00:00:00 2001 From: Tuan Dinh Date: Fri, 21 Aug 2026 10:07:45 +0700 Subject: [PATCH] fix(cursor): discover account Agent endpoint (#10804) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Requests AgentUrlConfig from Cursor with each selected account token and selects the account's actual server-assigned Agent endpoint (agentUrl/agentnUrl) instead of a fixed global/us host, which fails for teams pinned to a different region. Caches validated endpoints by connection+token. Closes #10802. Validated in an isolated worktree boarded onto origin/release/v3.8.50 (0 conflicts, 4 files): - 18/18 focused tests pass (cursor-agent-host, cursor-apikey-provider). - provider-translate-path-golden.test.ts initially failed — traced to a pre-existing base-red (stale golden snapshot left by an earlier freebuff merge, #10531, unrelated to this PR) and confirmed it reproduces on the pure release tip without this PR's changes. Fixed directly on release/v3.8.50 (mechanical key-ordering regen, values unchanged) rather than folding it into this PR's scope; green after merging that fix in. - check-file-size, check-changelog-integrity: OK. - typecheck:core: clean. - check-complexity / check-cognitive-complexity: OK, both under baseline. - Author additionally validated live: a real Cursor request selected agentn.us.api5.cursor.sh and returned HTTP 200/PING. Co-authored-by: tuandinh0801 --- open-sse/executors/cursor.ts | 41 +++++-- open-sse/executors/cursor/agentEndpoint.ts | 121 +++++++++++++++++++++ tests/unit/cursor-agent-host.test.ts | 109 +++++++++++++++++++ tests/unit/cursor-apikey-provider.test.ts | 35 ++++++ 4 files changed, 299 insertions(+), 7 deletions(-) create mode 100644 open-sse/executors/cursor/agentEndpoint.ts create mode 100644 tests/unit/cursor-agent-host.test.ts diff --git a/open-sse/executors/cursor.ts b/open-sse/executors/cursor.ts index bc9ade9d27..ebcfe053bd 100644 --- a/open-sse/executors/cursor.ts +++ b/open-sse/executors/cursor.ts @@ -82,6 +82,7 @@ import { visibleComposerContentFromThinking, composerReasoningRemainder, } from "./cursor/composer.ts"; +import { CursorServerConfigError, resolveCursorAgentUrl } from "./cursor/agentEndpoint.ts"; import { getActiveSyncedCatalog } from "../../src/lib/db/models/activeSyncedCatalog.ts"; // Composer helpers re-exported for external importers (tests). export { @@ -193,10 +194,6 @@ function buildExecRejection(event: ExecServerEvent): Buffer | null { } } -const CURSOR_AGENT_HOST = "agentn.global.api5.cursor.sh"; -const CURSOR_AGENT_PATH = "/agent.v1.AgentService/Run"; -const CURSOR_AGENT_URL = `https://${CURSOR_AGENT_HOST}${CURSOR_AGENT_PATH}`; - // Detect cloud environment (Edge runtime, Cloudflare Workers, etc.) const isCloudEnv = () => { if (typeof caches !== "undefined" && typeof caches === "object") return true; @@ -718,7 +715,7 @@ export class CursorExecutor extends BaseExecutor { } buildUrl() { - return CURSOR_AGENT_URL; + return PROVIDERS.cursor.baseUrl; } /** @@ -1211,10 +1208,40 @@ export class CursorExecutor extends BaseExecutor { } async execute({ model, body, stream, credentials, signal, log, upstreamExtraHeaders }) { - const url = this.buildUrl(); + const fallbackUrl = this.buildUrl(); const executionCredentials = await this.resolveExecutionCredentials(credentials); if (executionCredentials instanceof Response) { - return { response: executionCredentials, url, headers: {}, transformedBody: body }; + return { + response: executionCredentials, + url: fallbackUrl, + headers: {}, + transformedBody: body, + }; + } + let url: string; + try { + url = await resolveCursorAgentUrl(executionCredentials, signal); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const headers = this.buildHeaders(executionCredentials); + return { + response: new Response( + JSON.stringify({ + error: { + message: sanitizeErrorMessage(message), + type: "connection_error", + code: "", + }, + }), + { + status: err instanceof CursorServerConfigError ? err.status : HTTP_STATUS.SERVER_ERROR, + headers: { "Content-Type": "application/json" }, + } + ), + url: fallbackUrl, + headers, + transformedBody: body, + }; } const headers = this.buildHeaders(executionCredentials); mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders); diff --git a/open-sse/executors/cursor/agentEndpoint.ts b/open-sse/executors/cursor/agentEndpoint.ts new file mode 100644 index 0000000000..e1168df5b7 --- /dev/null +++ b/open-sse/executors/cursor/agentEndpoint.ts @@ -0,0 +1,121 @@ +import { createHmac } from "node:crypto"; + +import { mergeAbortSignals, type ProviderCredentials } from "../base.ts"; +import { stripCursorOAuthTokenPrefix } from "../../services/cursorApiKeyAuth.ts"; +import { + formatCursorAgentClientVersion, + getCursorAgentCliVersion, +} from "../../utils/cursorAgentCliVersion.ts"; +import { decodeFields } from "../../utils/cursorAgentProtobuf/wire.ts"; + +const CURSOR_API_URL = "https://api2.cursor.sh"; +const CURSOR_SERVER_CONFIG_PATH = "/aiserver.v1.ServerConfigService/GetServerConfig"; +const CURSOR_AGENT_PATH = "/agent.v1.AgentService/Run"; +const CURSOR_SERVER_CONFIG_TIMEOUT_MS = 10_000; +const CURSOR_AGENT_URL_CACHE_TTL_MS = 60 * 60 * 1000; +const CURSOR_AGENT_URL_CACHE_LIMIT = 1_000; + +type CursorAgentUrls = { agentUrl: string; agentnUrl: string }; +type CursorAgentUrlCacheEntry = CursorAgentUrls & { expiresAt: number }; +const cursorAgentUrlCache = new Map(); + +/** Reports an HTTP error from Cursor server-config discovery. */ +export class CursorServerConfigError extends Error { + constructor( + message: string, + readonly status: number + ) { + super(message); + } +} + +function validateCursorAgentUrl(value: string): string { + const url = new URL(value); + const isCursorAgentHost = + url.hostname === "api5.cursor.sh" || url.hostname.endsWith(".api5.cursor.sh"); + if ( + url.protocol !== "https:" || + !isCursorAgentHost || + url.username || + url.password || + url.search || + url.hash + ) { + throw new Error("Cursor server config included an invalid Agent URL"); + } + return url.origin; +} + +function parseCursorAgentUrls(payload: Buffer): CursorAgentUrls { + const agentUrlConfig = decodeFields(payload).find( + (field) => field.fieldNumber === 27 && field.wireType === 2 + ); + if (!agentUrlConfig || agentUrlConfig.wireType !== 2) { + throw new Error("Cursor server config did not include Agent URLs"); + } + const fields = decodeFields(agentUrlConfig.bytes); + const agentUrl = fields.find((field) => field.fieldNumber === 1 && field.wireType === 2); + const agentnUrl = fields.find((field) => field.fieldNumber === 2 && field.wireType === 2); + if (!agentUrl || agentUrl.wireType !== 2 || !agentnUrl || agentnUrl.wireType !== 2) { + throw new Error("Cursor server config included incomplete Agent URLs"); + } + return { + agentUrl: validateCursorAgentUrl(agentUrl.bytes.toString("utf8")), + agentnUrl: validateCursorAgentUrl(agentnUrl.bytes.toString("utf8")), + }; +} + +async function fetchCursorAgentUrls( + accessToken: string, + signal?: AbortSignal | null +): Promise { + const timeoutSignal = AbortSignal.timeout(CURSOR_SERVER_CONFIG_TIMEOUT_MS); + const response = await fetch(`${CURSOR_API_URL}${CURSOR_SERVER_CONFIG_PATH}`, { + method: "POST", + headers: { + authorization: `Bearer ${accessToken}`, + "connect-protocol-version": "1", + "content-type": "application/proto", + "user-agent": "connect-es/1.6.1", + "x-cursor-client-type": "cli", + "x-cursor-client-version": formatCursorAgentClientVersion(getCursorAgentCliVersion()), + }, + body: Buffer.alloc(0), + signal: signal ? mergeAbortSignals(signal, timeoutSignal) : timeoutSignal, + }); + if (!response.ok) { + throw new CursorServerConfigError( + `Cursor server config request failed with status ${response.status}`, + response.status + ); + } + return parseCursorAgentUrls(Buffer.from(await response.arrayBuffer())); +} + +/** Resolve the Agent RPC URL that Cursor assigned to this connection. */ +export async function resolveCursorAgentUrl( + credentials: ProviderCredentials, + signal?: AbortSignal | null +): Promise { + const accessToken = stripCursorOAuthTokenPrefix(credentials.accessToken || ""); + if (!accessToken) throw new Error("Cursor access token is required"); + const cacheKey = + `${credentials.connectionId || "anonymous"}:` + + createHmac("sha256", "omniroute-cursor-agent-url-cache-v1").update(accessToken).digest("hex"); + const now = Date.now(); + let urls = cursorAgentUrlCache.get(cacheKey); + if (!urls || urls.expiresAt <= now) { + const fetched = await fetchCursorAgentUrls(accessToken, signal); + urls = { ...fetched, expiresAt: now + CURSOR_AGENT_URL_CACHE_TTL_MS }; + if ( + !cursorAgentUrlCache.has(cacheKey) && + cursorAgentUrlCache.size >= CURSOR_AGENT_URL_CACHE_LIMIT + ) { + const oldestKey = cursorAgentUrlCache.keys().next().value as string | undefined; + if (oldestKey !== undefined) cursorAgentUrlCache.delete(oldestKey); + } + cursorAgentUrlCache.set(cacheKey, urls); + } + const ghostMode = credentials.providerSpecificData?.ghostMode !== false; + return `${ghostMode ? urls.agentUrl : urls.agentnUrl}${CURSOR_AGENT_PATH}`; +} diff --git a/tests/unit/cursor-agent-host.test.ts b/tests/unit/cursor-agent-host.test.ts new file mode 100644 index 0000000000..5447336726 --- /dev/null +++ b/tests/unit/cursor-agent-host.test.ts @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveCursorAgentUrl } from "../../open-sse/executors/cursor/agentEndpoint.ts"; +import { encodeMessage, encodeString } from "../../open-sse/utils/cursorAgentProtobuf/wire.ts"; + +function serverConfig(agentUrl: string, agentnUrl: string): Buffer { + return encodeMessage(27, [encodeString(1, agentUrl), encodeString(2, agentnUrl)]); +} + +test("Cursor Agent uses each connection's server-assigned endpoint", async () => { + const originalFetch = globalThis.fetch; + const requestedTokens: string[] = []; + globalThis.fetch = async (input, init) => { + assert.equal( + String(input), + "https://api2.cursor.sh/aiserver.v1.ServerConfigService/GetServerConfig" + ); + const token = new Headers(init?.headers).get("authorization") ?? ""; + requestedTokens.push(token); + const region = token === "Bearer token-us" ? "us" : "eu"; + return new Response( + serverConfig( + `https://agent.${region}.api5.cursor.sh`, + `https://agentn.${region}.api5.cursor.sh` + ), + { status: 200, headers: { "Content-Type": "application/proto" } } + ); + }; + + try { + const usCredentials = { + accessToken: "token-us", + connectionId: "connection-us", + providerSpecificData: { ghostMode: false }, + }; + assert.equal( + await resolveCursorAgentUrl(usCredentials), + "https://agentn.us.api5.cursor.sh/agent.v1.AgentService/Run" + ); + assert.equal( + await resolveCursorAgentUrl(usCredentials), + await resolveCursorAgentUrl(usCredentials) + ); + assert.equal( + await resolveCursorAgentUrl({ + accessToken: "token-eu", + connectionId: "connection-eu", + providerSpecificData: { ghostMode: true }, + }), + "https://agent.eu.api5.cursor.sh/agent.v1.AgentService/Run" + ); + assert.deepEqual(requestedTokens, ["Bearer token-us", "Bearer token-eu"]); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("Cursor Agent uses the token with the connection cache key", async () => { + const originalFetch = globalThis.fetch; + const requestedTokens: string[] = []; + globalThis.fetch = async (_input, init) => { + const token = new Headers(init?.headers).get("authorization") ?? ""; + requestedTokens.push(token); + const region = token.endsWith("new") ? "new" : "old"; + return new Response( + serverConfig( + `https://agent.${region}.api5.cursor.sh`, + `https://agentn.${region}.api5.cursor.sh` + ), + { status: 200, headers: { "Content-Type": "application/proto" } } + ); + }; + + try { + assert.equal( + await resolveCursorAgentUrl({ accessToken: "token-old", connectionId: "connection-rotate" }), + "https://agent.old.api5.cursor.sh/agent.v1.AgentService/Run" + ); + assert.equal( + await resolveCursorAgentUrl({ accessToken: "token-new", connectionId: "connection-rotate" }), + "https://agent.new.api5.cursor.sh/agent.v1.AgentService/Run" + ); + assert.deepEqual(requestedTokens, ["Bearer token-old", "Bearer token-new"]); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("Cursor Agent rejects an endpoint outside Cursor's API domain", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response( + serverConfig("https://attacker.example/agent", "https://attacker.example/agentn"), + { status: 200, headers: { "Content-Type": "application/proto" } } + ); + + try { + await assert.rejects( + resolveCursorAgentUrl({ + accessToken: "token-invalid-host", + connectionId: "connection-invalid-host", + }), + /invalid Agent URL/ + ); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/cursor-apikey-provider.test.ts b/tests/unit/cursor-apikey-provider.test.ts index b705d2fc93..da803448e3 100644 --- a/tests/unit/cursor-apikey-provider.test.ts +++ b/tests/unit/cursor-apikey-provider.test.ts @@ -128,6 +128,41 @@ describe("CursorExecutor credential resolution", () => { const resolved = await executor.resolveExecutionCredentials(credentials); assert.equal(resolved, credentials); }); + + it("exchanges a crsr_ key before Agent endpoint discovery", async () => { + const exp = Math.floor(Date.now() / 1000) + 3600; + const sessionToken = jwt(exp); + const calls: Array<{ url: string; authorization: string }> = []; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + calls.push({ + url, + authorization: new Headers(init?.headers).get("authorization") ?? "", + }); + if (url.endsWith("/auth/exchange_user_api_key")) { + return new Response( + JSON.stringify({ accessToken: sessionToken, refreshToken: sessionToken }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + return new Response(Buffer.alloc(0), { status: 200 }); + }) as typeof fetch; + + const executor = new CursorExecutor("cursor-api"); + const result = await executor.execute({ + model: "auto", + body: { messages: [] }, + stream: false, + credentials: { apiKey: API_KEY, connectionId: "cursor-api-test" }, + }); + + assert.equal(result.response.status, 500); + assert.equal(calls.length, 2); + assert.match(calls[0].url, /\/auth\/exchange_user_api_key$/); + assert.match(calls[1].url, /ServerConfigService\/GetServerConfig$/); + assert.equal(calls[1].authorization, `Bearer ${sessionToken}`); + assert.ok(!calls[1].authorization.includes(API_KEY)); + }); }); describe("cursor-api connection test", () => {