mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-21 06:32:16 +03:00
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:
@@ -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);
|
||||
|
||||
121
open-sse/executors/cursor/agentEndpoint.ts
Normal file
121
open-sse/executors/cursor/agentEndpoint.ts
Normal 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}`;
|
||||
}
|
||||
109
tests/unit/cursor-agent-host.test.ts
Normal file
109
tests/unit/cursor-agent-host.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user