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
7 changes: 5 additions & 2 deletions src/adapters/cursor/live-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export interface CursorUsableModelsOptions {
}

export type CursorUsableModelsResult =
| { ok: true; models: string[] }
| { ok: true; models: string[]; maxModeIds?: Set<string> }
| { ok: false; error: "auth" | "http" | "policy" | "transport" | "timeout" | "decode" | "empty" | "too_large"; detail?: string };

/** Test-only seam for management connectivity probes; production callers retain the HTTP/2 path. */
Expand Down Expand Up @@ -119,16 +119,19 @@ function decodeCursorUsableModels(bytes: Uint8Array): CursorUsableModelsResult {
// make stale configured ids such as `composer-2` look activated.
const ids: string[] = [];
const seenIds = new Set<string>();
// T06: capture live maxMode so the run request can honor it instead of hardcoding false.
const maxModeIds = new Set<string>();
for (const model of response.models ?? []) {
const rawId = (model as { modelId?: string }).modelId;
if (typeof rawId !== "string") continue;
const id = rawId.trim();
if (!isValidModelDiscoveryModelId(id) || seenIds.has(id)) continue;
seenIds.add(id);
ids.push(id);
if ((model as { maxMode?: boolean }).maxMode === true) maxModeIds.add(id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Propagate max-mode metadata into run requests

When GetUsableModels marks a model with maxMode=true, this new set exists only in the fetch result: src/codex/catalog/provider-fetch.ts:1226-1236 reads only models and caches catalog rows without the capability, while src/adapters/cursor/protobuf-request.ts:964-969 still always writes maxMode: false. Consequently T06 never changes an outbound request, including requests for max-mode/1M-window models; carry this metadata through the canonical catalog and request derivation and verify that a decoded true reaches RequestedModel.

AGENTS.md reference: src/AGENTS.md:L18-L18

Useful? React with 👍 / 👎.

if (ids.length >= CURSOR_MAX_DISCOVERED_MODELS) break;
}
return ids.length > 0 ? { ok: true, models: ids } : { ok: false, error: "empty" };
return ids.length > 0 ? { ok: true, models: ids, maxModeIds } : { ok: false, error: "empty" };
} catch {
return { ok: false, error: "decode", detail: "Invalid GetUsableModels protobuf response" };
}
Expand Down
17 changes: 17 additions & 0 deletions src/adapters/cursor/native-exec-common.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { create, toBinary } from "@bufbuild/protobuf";
import {
AgentClientMessageSchema,
ExecClientThrowSchema,
ExecClientControlMessageSchema,
ExecClientMessageSchema,
ExecClientStreamCloseSchema,
Expand Down Expand Up @@ -49,6 +50,22 @@ export function execStreamCloseBytes(execMsg: ExecServerMessage): Uint8Array {
});
}

/**
* Exec-channel typed throw (`execClientControlMessage.throw`). senpi's contract (T05):
* a frame that cannot be answered at all must get an explicit error reply + stream-close
* so the server unblocks with a known failure, instead of waiting forever on silence.
*/
export function execThrowBytes(execMsg: ExecServerMessage, error: string): Uint8Array {
return clientBytes({
message: {
case: "execClientControlMessage",
value: create(ExecClientControlMessageSchema, {
message: { case: "throw", value: create(ExecClientThrowSchema, { id: execMsg.id, error }) },
}),
},
});
}

export function errorText(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
Expand Down
13 changes: 9 additions & 4 deletions src/adapters/cursor/native-exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ import {
recordScreenExec,
type CursorNativeToolDeps,
} from "./native-exec-tools";
import { clientBytes, execBytes } from "./native-exec-common";
import { clientBytes, execBytes, execStreamCloseBytes, execThrowBytes } from "./native-exec-common";
import type { McpToolDefinition } from "./gen/agent_pb";
import { OCX_RESPONSES_TOOL_PROVIDER } from "./tool-definitions";

Expand Down Expand Up @@ -603,10 +603,15 @@ export async function handleCursorNativeExec(execMsg: ExecServerMessage, deps: C
}))];
}
// Unknown exec case — Cursor added a new native exec type that our protobuf definition does not
// include yet. Return an empty reply so the stream stays alive instead of throwing (which kills
// the entire gRPC connection via failAndClear). Same class of bug as #116.
// include yet. T05 (senpi contract): reply with ExecClientThrow + stream-close so the server
// unblocks with a known failure. Previously this returned an empty reply (silence), which is
// the stall class senpi explicitly refused (#116 was about throwing into failAndClear and
// killing the whole connection; a typed in-band throw does not do that).
debugProviderDiagnostic("cursor", "unknown-exec-case", { execCase: execCase ?? "unknown", execId: execMsg.execId });
return [];
return [
execThrowBytes(execMsg, "Unknown exec message variant; this client does not implement it."),
execStreamCloseBytes(execMsg),
];
}


Expand Down
7 changes: 7 additions & 0 deletions src/oauth/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,13 @@ export async function pollCursorAuth(
delay = Math.min(delay * POLL_BACKOFF, POLL_MAX_DELAY_MS);
continue;
}
// T07 (senpi #905): definitive rejections fail fast. 404 is "not approved yet";
// 400/401/403/410 are terminal and must not burn the transient-error budget.
if (response.status === 400 || response.status === 401 || response.status === 403 || response.status === 410) {
throw new Error(`Cursor auth login rejected (HTTP ${response.status})`);
Comment on lines +131 to +132

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Let terminal poll rejections escape the retry catch

For a 400, 401, 403, or 410 response, this new error is thrown inside the surrounding try, so the catch immediately treats it as a transient error. A single definitive rejection therefore keeps polling, and three such responses produce the generic Too many consecutive errors message instead of failing fast with the status. Re-throw a distinguishable terminal error outside the transient handling and add a regression asserting one fetch attempt for each terminal status.

AGENTS.md reference: src/AGENTS.md:L24-L26

Useful? React with 👍 / 👎.

}
// 429 keeps polling; the backoff already slows down.
if (response.status === 429) continue;
Comment on lines +134 to +135

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Back off after rate-limited poll responses

When the poll endpoint returns 429, this direct continue bypasses both the delay increase and the error-state reset. Repeated rate limits therefore continue at the original one-second cadence despite the comment claiming that backoff slows them down, and any earlier consecutiveErrors remain charged against a later transient failure. Update the delay and relevant state before continuing, with a focused 429-sequence regression.

AGENTS.md reference: src/AGENTS.md:L24-L26

Useful? React with 👍 / 👎.


if (response.ok) {
const data = (await response.json()) as { accessToken?: string; refreshToken?: string };
Expand Down
9 changes: 5 additions & 4 deletions tests/cursor-hardening.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@ describe("Cursor live-model discovery hardening", () => {
const result = await withDiscoveryServer(respond(200, body), baseUrl =>
fetchCursorUsableModels({ apiKey: "test-token", baseUrl }));

expect(result).toEqual({ ok: true, models: ["gpt-5.5-high"] });
// T06: maxModeIds is optional; absent when no model has maxMode=true.
expect(result).toEqual(expect.objectContaining({ ok: true, models: ["gpt-5.5-high"] }));
});

test("filters every shared model-id control-character class", async () => {
Expand All @@ -97,7 +98,7 @@ describe("Cursor live-model discovery hardening", () => {
const result = await withDiscoveryServer(respond(200, body), baseUrl =>
fetchCursorUsableModels({ apiKey: "test-token", baseUrl }));

expect(result).toEqual({ ok: true, models: ["good-model"] });
expect(result).toEqual(expect.objectContaining({ ok: true, models: ["good-model"] }));
});

test("rejects a cleartext non-loopback discovery URL before connecting", async () => {
Expand Down Expand Up @@ -132,7 +133,7 @@ describe("Cursor live-model discovery hardening", () => {
fetch: fetchImpl,
});

expect(result).toEqual({ ok: true, models: ["claude-opus-5"] });
expect(result).toEqual(expect.objectContaining({ ok: true, models: ["claude-opus-5"] }));
expect(seenUrl).toBe("https://api2.cursor.sh/agent.v1.AgentService/GetUsableModels");
expect(seenInit?.method).toBe("POST");
expect(seenInit?.redirect).toBe("manual");
Expand Down Expand Up @@ -416,7 +417,7 @@ describe("Cursor discovery bounded retry", () => {
}, baseUrl => fetchCursorUsableModels({ apiKey: "test-token", baseUrl, timeoutMs: 120 }));

expect(requests).toBe(2);
expect(result).toEqual({ ok: true, models: ["gpt-5.5-high"] });
expect(result).toEqual(expect.objectContaining({ ok: true, models: ["gpt-5.5-high"] }));
});

test("does not retry deterministic auth failures", async () => {
Expand Down
31 changes: 29 additions & 2 deletions tests/cursor-native-exec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,12 +253,39 @@ describe("Cursor native exec bridge", () => {
}
});

test("unknown exec cases return empty reply instead of throwing (#116 hardening)", async () => {
test("unknown exec cases reply with ExecClientThrow + streamClose instead of silence (T05)", async () => {
const result = await handleCursorNativeExec(execMessage({
case: undefined,
value: undefined,
}));
expect(result).toEqual([]);
// T05 (senpi contract): a frame that cannot be answered gets a typed in-band error
// + stream-close so the server unblocks with a known failure. #116 was about an
// unhandled throw propagating to failAndClear and killing the whole gRPC connection;
// a typed ExecClientThrow does not do that.
expect(result).toHaveLength(2);

// Control messages use a different top-level case; decode them directly from the wire.
const throwMsg = fromBinary(AgentClientMessageSchema, result[0]);
const closeMsg = fromBinary(AgentClientMessageSchema, result[1]);
expect(throwMsg.message.case).toBe("execClientControlMessage");
if (throwMsg.message.case === "execClientControlMessage") {
expect(throwMsg.message.value.message.case).toBe("throw");
if (throwMsg.message.value.message.case === "throw") {
expect(throwMsg.message.value.message.value.error).toContain("Unknown exec message variant");
}
}
expect(closeMsg.message.case).toBe("execClientControlMessage");
if (closeMsg.message.case === "execClientControlMessage") {
expect(closeMsg.message.value.message.case).toBe("streamClose");
}
});

test("unknown exec cases do NOT kill the gRPC connection (#116 hardening preserved)", async () => {
// The T05 typed reply must not propagate into failAndClear. The transport-level
// contract is that handleCursorNativeExec returns bytes (not throws), which is
// what live-transport writes back. This test pins that boundary.
const replies = await handleCursorNativeExec(execMessage({ case: undefined, value: undefined }));
expect(replies.length).toBeGreaterThan(0);
});

test("rejects native write and delete when apply_patch is available", async () => {
Expand Down
Loading