Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/fixes/cursor-build-headers-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Keep `CursorExecutor.buildHeaders` synchronous (and expose `getCursorAgentCliVersionSync`) so the executor honors the `BaseExecutor` contract again; the async override from the CLI-version refresh broke the types and would hand a Promise to callers that do not await.
10 changes: 5 additions & 5 deletions open-sse/executors/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import {
} from "../utils/usageTracking.ts";
import {
formatCursorAgentClientVersion,
getCursorAgentCliVersion,
getCursorAgentCliVersionSync,
} from "../utils/cursorAgentCliVersion.ts";
import { sanitizeErrorMessage } from "../utils/error.ts";
import { generateToolCallId } from "../translator/helpers/toolCallHelper.ts";
Expand Down Expand Up @@ -1022,12 +1022,12 @@ export class CursorExecutor extends BaseExecutor {
}
}

async buildHeaders(credentials) {
buildHeaders(credentials) {
const ghostMode = credentials.providerSpecificData?.ghostMode !== false;
const cleanToken = stripCursorOAuthTokenPrefix(credentials.accessToken ?? "");
const requestId = crypto.randomUUID();
const traceParent = `00-${crypto.randomBytes(16).toString("hex")}-${crypto.randomBytes(8).toString("hex")}-01`;
const clientVersion = formatCursorAgentClientVersion(await getCursorAgentCliVersion());
const clientVersion = formatCursorAgentClientVersion(getCursorAgentCliVersionSync());

// Mirrors cursor-agent's actual headers for agent.v1.AgentService/Run.
// Notably: no x-cursor-checksum, no machineId, no x-amzn-trace-id.
Expand Down Expand Up @@ -1254,7 +1254,7 @@ export class CursorExecutor extends BaseExecutor {
url = await resolveCursorAgentUrl(executionCredentials, signal);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const headers = await this.buildHeaders(executionCredentials);
const headers = this.buildHeaders(executionCredentials);
return {
response: new Response(
JSON.stringify({
Expand All @@ -1274,7 +1274,7 @@ export class CursorExecutor extends BaseExecutor {
transformedBody: body,
};
}
const headers = await this.buildHeaders(executionCredentials);
const headers = this.buildHeaders(executionCredentials);
mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders);

const messages: ChatMessage[] = body.messages || [];
Expand Down
12 changes: 11 additions & 1 deletion open-sse/utils/cursorAgentCliVersion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ function resolveLocalCursorAgentCliVersion(now: number): string | null {
* (20s timeout, cached 6h) and used only when its date is strictly later than
* the pin. A rejected or unusable fetch keeps the pin.
*/
export async function getCursorAgentCliVersion(): Promise<string> {
export function getCursorAgentCliVersionSync(): string {
const now = Date.now();
if (cachedVersion && now - cachedAt < CACHE_TTL_MS) {
return cachedVersion;
Expand Down Expand Up @@ -261,6 +261,16 @@ export async function getCursorAgentCliVersion(): Promise<string> {
return resolved;
}

/**
* Async form of {@link getCursorAgentCliVersionSync}. Nothing here awaits: the
* lookup answers from env, a local install, the disk cache or the pin, and the
* installer scrape runs in the background. Prefer the sync form wherever a
* synchronous contract applies (e.g. `buildHeaders`).
*/
export async function getCursorAgentCliVersion(): Promise<string> {
return getCursorAgentCliVersionSync();
}

/**
* Await a remote installer scrape (tests / warm-up). Writes disk cache on success.
*/
Expand Down
31 changes: 31 additions & 0 deletions tests/unit/cursor-build-headers-sync-contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import test from "node:test";
import assert from "node:assert/strict";

// BaseExecutor.buildHeaders is synchronous and base-class paths (e.g. the
// probe in BaseExecutor) use its return value directly. CursorExecutor made it
// async when the CLI client version became refreshable (ef3b71c6), which broke
// the override contract (TS2416) and would hand a Promise to any caller that
// does not await. The version lookup never blocks (cache / pin, scrape runs in
// the background), so the override must stay synchronous.

const { CursorExecutor } = await import("../../open-sse/executors/cursor.ts");
const { getCursorAgentCliVersion, getCursorAgentCliVersionSync } =
await import("../../open-sse/utils/cursorAgentCliVersion.ts");

test("CursorExecutor.buildHeaders returns a plain header object, not a Promise", () => {
const headers = new CursorExecutor("cursor").buildHeaders({
accessToken: "tok-123",
providerSpecificData: {},
} as never);
assert.ok(!(headers instanceof Promise), "buildHeaders must stay synchronous");
assert.equal(typeof headers, "object");
assert.match(String((headers as Record<string, string>)["x-cursor-client-version"]), /\S/);
assert.match(
String((headers as Record<string, string>)["authorization"] ?? ""),
/Bearer tok-123/i
);
});

test("the sync and async client-version lookups agree", async () => {
assert.equal(getCursorAgentCliVersionSync(), await getCursorAgentCliVersion());
});
Loading