From b513a9142c01fd028dd2bae1f64db06025ee06c6 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 22 Aug 2026 10:51:29 +0900 Subject: [PATCH 1/2] fix(cursor): unknown exec replies with ExecClientThrow + streamClose instead of silence (#2322) 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. Previously this returned an empty reply (silence), which is the stall class senpi explicitly refused. #116 was about an unhandled throw propagating to failAndClear and killing the whole gRPC connection; a typed ExecClientThrow does not do that. Research unit: devlog/_plan/260822_senpi_cursor_transfer/090 T05. --- src/adapters/cursor/native-exec-common.ts | 17 +++++++++++++ src/adapters/cursor/native-exec.ts | 13 +++++++--- tests/cursor-native-exec.test.ts | 31 +++++++++++++++++++++-- 3 files changed, 55 insertions(+), 6 deletions(-) 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/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 () => { From 56eb69f543f3ba3137090f9949b73ea7c3e7b023 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 22 Aug 2026 08:56:27 +0900 Subject: [PATCH 2/2] feat(cursor): live GetUsableModels.maxMode decode + OAuth poll fail-fast on definitive rejections T06: decode the maxMode field from GetUsableModels and return it alongside model ids so callers can honor it instead of hardcoding RequestedModel.maxMode to false. The field already exists in the generated proto (agent_pb.ts:2667). T07 (senpi #905): OAuth poll fail-fasts on 400/401/403/410 instead of burning the transient-error budget. 404 remains 'not approved yet'; 429 keeps polling. Research unit: devlog/_plan/260822_senpi_cursor_transfer/090 T06+T07. --- src/adapters/cursor/live-models.ts | 7 +++++-- src/oauth/cursor.ts | 7 +++++++ tests/cursor-hardening.test.ts | 9 +++++---- 3 files changed, 17 insertions(+), 6 deletions(-) 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/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 () => {