fix(cursor): discover account Agent endpoint (#10804)

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 <tuandinh0801@users.noreply.github.com>
This commit is contained in:
Tuan Dinh
2026-08-21 10:07:45 +07:00
committed by GitHub
parent 6098954b0b
commit fa0cd5af1c
4 changed files with 299 additions and 7 deletions

View File

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

View File

@@ -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<string, CursorAgentUrlCacheEntry>();
/** 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<CursorAgentUrls> {
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<string> {
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}`;
}

View File

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

View File

@@ -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", () => {