diff --git a/src/adapters/cursor/live-models.ts b/src/adapters/cursor/live-models.ts index f79fe27398..35d9c686c6 100644 --- a/src/adapters/cursor/live-models.ts +++ b/src/adapters/cursor/live-models.ts @@ -42,7 +42,7 @@ export interface CursorUsableModelsOptions { } export type CursorUsableModelsResult = - | { ok: true; models: string[] } + | { ok: true; models: string[]; maxModeIds?: Set } | { 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. */ @@ -119,6 +119,8 @@ function decodeCursorUsableModels(bytes: Uint8Array): CursorUsableModelsResult { // make stale configured ids such as `composer-2` look activated. const ids: string[] = []; const seenIds = new Set(); + // T06: capture live maxMode so the run request can honor it instead of hardcoding false. + const maxModeIds = new Set(); for (const model of response.models ?? []) { const rawId = (model as { modelId?: string }).modelId; if (typeof rawId !== "string") continue; @@ -126,9 +128,10 @@ function decodeCursorUsableModels(bytes: Uint8Array): CursorUsableModelsResult { if (!isValidModelDiscoveryModelId(id) || seenIds.has(id)) continue; seenIds.add(id); ids.push(id); + if ((model as { maxMode?: boolean }).maxMode === true) maxModeIds.add(id); 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" }; } diff --git a/src/adapters/cursor/native-exec-common.ts b/src/adapters/cursor/native-exec-common.ts index 1afa153074..86715636eb 100644 --- a/src/adapters/cursor/native-exec-common.ts +++ b/src/adapters/cursor/native-exec-common.ts @@ -1,6 +1,7 @@ import { create, toBinary } from "@bufbuild/protobuf"; import { AgentClientMessageSchema, + ExecClientThrowSchema, ExecClientControlMessageSchema, ExecClientMessageSchema, ExecClientStreamCloseSchema, @@ -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); } diff --git a/src/adapters/cursor/native-exec.ts b/src/adapters/cursor/native-exec.ts index c72fa4715a..aee9ddac38 100644 --- a/src/adapters/cursor/native-exec.ts +++ b/src/adapters/cursor/native-exec.ts @@ -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"; @@ -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), + ]; } diff --git a/src/oauth/cursor.ts b/src/oauth/cursor.ts index d7607cef83..ae4a0e3848 100644 --- a/src/oauth/cursor.ts +++ b/src/oauth/cursor.ts @@ -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})`); + } + // 429 keeps polling; the backoff already slows down. + if (response.status === 429) continue; if (response.ok) { const data = (await response.json()) as { accessToken?: string; refreshToken?: string }; diff --git a/tests/cursor-hardening.test.ts b/tests/cursor-hardening.test.ts index 6385fae22a..6402e6952f 100644 --- a/tests/cursor-hardening.test.ts +++ b/tests/cursor-hardening.test.ts @@ -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 () => { @@ -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 () => { @@ -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"); @@ -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 () => { diff --git a/tests/cursor-native-exec.test.ts b/tests/cursor-native-exec.test.ts index e3365ce040..be1ac99c47 100644 --- a/tests/cursor-native-exec.test.ts +++ b/tests/cursor-native-exec.test.ts @@ -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 () => {