diff --git a/.env.example b/.env.example index 9a79c76352..16a61c2444 100644 --- a/.env.example +++ b/.env.example @@ -1180,6 +1180,16 @@ APP_LOG_TO_FILE=true # Used by: open-sse/executors/cursor.ts. # CURSOR_STREAM_TIMEOUT_MS=300000 +# Cursor tool-commit directive toggle. Default-on: when a request declares +# tools, a directive is prepended so composer-2.5 reliably issues tool calls +# instead of narrating intent. Set to 0 to disable. +# Used by: open-sse/executors/cursor.ts. +# CURSOR_TOOL_DIRECTIVE=1 + +# Per-image fetch timeout (ms) for remote image_url vision input. Default: 15000. +# Used by: open-sse/utils/cursorImages.ts. +# CURSOR_IMAGE_FETCH_TIMEOUT_MS=15000 + # Cursor state DB path override (for cursor version detection). # Used by: open-sse/utils/cursorVersionDetector.ts. Default: probed automatically. # CURSOR_STATE_DB_PATH= diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 87d6b3f39d..c3be99fc53 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -739,6 +739,8 @@ Anthropic-compatible provider instead. | `CURSOR_STREAM_DEBUG` | _(unset)_ | `open-sse/executors/cursor.ts` | Backward-compatible alias of `CURSOR_DEBUG`. | | `CURSOR_DUMP_FILE` | _(unset)_ | `open-sse/executors/cursor.ts` | Optional file path that receives raw decoded Cursor chunks when `CURSOR_DEBUG=1`. | | `CURSOR_STREAM_TIMEOUT_MS` | `300000` | `open-sse/executors/cursor.ts` | Stream idle timeout (ms) for the Cursor executor. | +| `CURSOR_TOOL_DIRECTIVE` | enabled (`!== "0"`) | `open-sse/executors/cursor.ts` | Tool-commit directive that makes composer-2.5 reliably issue tool calls. Set `0` to disable. | +| `CURSOR_IMAGE_FETCH_TIMEOUT_MS` | `15000` | `open-sse/utils/cursorImages.ts` | Per-image fetch timeout (ms) for remote `image_url` vision input. | | `CURSOR_STATE_DB_PATH` | _(probed)_ | `open-sse/utils/cursorVersionDetector.ts` | Override the Cursor state DB lookup used for version detection. | | `CURSOR_TOKEN` | _(unset)_ | `scripts/ad-hoc/cursor-tap.cjs` | Direct Cursor bearer token used by developer tooling. | | `OMNIROUTE_LOG_REQUEST_SHAPE` | enabled (`!== "0"`) | `src/app/api/v1/chat/completions/route.ts` | Log content-type/length markers for large chat payloads. Set `"0"` to silence. | diff --git a/open-sse/executors/cursor.ts b/open-sse/executors/cursor.ts index db0ca9b0f6..0622ec7a76 100644 --- a/open-sse/executors/cursor.ts +++ b/open-sse/executors/cursor.ts @@ -33,10 +33,16 @@ import { flattenMessages, openAIToolsToMcpDefs, type ChatMessage, + type EncodedImage, type ExecServerEvent, type McpToolDefinition, type OpenAITool, } from "../utils/cursorAgentProtobuf.ts"; +import { + resolveCursorImages, + extractImageUrls, + CursorImageError, +} from "../utils/cursorImages.ts"; import { estimateInputTokens, estimateOutputTokens, @@ -58,6 +64,97 @@ const BUILTIN_TOOL_REJECT_REASON = "Tool not available in this environment. Use the MCP tools provided instead."; const gunzipAsync = promisify(zlib.gunzip); +// Tool-commit directive — adapted from composer-api's TOOL_SYSTEM_DIRECTIVE. +// composer-2.5 otherwise narrates intent ("Checking the weather...") and ends +// the turn ~20% of the time instead of actually invoking a declared tool. This +// directive, prepended to the user text only when the request declares tools, +// tells the model to commit to the tool call rather than describe it as prose. +const TOOL_COMMIT_DIRECTIVE = [ + "You are serving an OpenAI-compatible API request and the client has provided executable tools.", + "When a tool is needed to answer (real-time data, web/search lookups, file or project operations), you MUST issue the actual tool call. Do NOT describe what you are about to do as prose and then stop — call the tool.", + "Answer directly only when no tool is needed.", + "Do not emit duplicate tool calls: call each operation once, then continue after the tool result is returned.", + "Never claim that tools are unavailable.", +].join("\n"); + +// NOTE: composer-api primes the model into "agent mode" with a fabricated +// prior switch_mode exchange (AGENT_MODE_PRIMER). On OmniRoute's native-tool +// agent endpoint that primer is counterproductive — it references a +// non-existent switch_mode tool and measurably LOWERED the tool-call rate in +// live A/B (56% vs 69%), so it is intentionally not ported. + +function isRecordLike(v: unknown): v is Record { + return typeof v === "object" && v !== null; +} + +/** + * Translate OpenAI `tool_choice` into an extra directive line — cursor's agent + * endpoint has no native equivalent. `"required"` forces some tool; a specific + * `{type:"function", function:{name}}` forces that tool. `"auto"`/`"none"`/ + * absent add nothing here ("none" is handled by dropping tools entirely). + * Ported from composer-api (directToolChoiceHint / tool_choice === "required"). + */ +function toolChoiceDirectiveLine(toolChoice: unknown): string { + if (toolChoice === "required") { + return "\nYou MUST call at least one of the available tools now; do not answer without calling a tool."; + } + if ( + isRecordLike(toolChoice) && + toolChoice.type === "function" && + isRecordLike(toolChoice.function) && + typeof toolChoice.function.name === "string" && + toolChoice.function.name + ) { + return `\nYou MUST call the \`${toolChoice.function.name}\` tool now and not any other tool.`; + } + return ""; +} + +/** + * Build an OUTPUT CONSTRAINTS block from OpenAI request params that cursor's + * agent endpoint silently ignores (response_format / max_tokens / stop), so + * they're surfaced to the model as prompt instructions instead. Ported from + * composer-api (appendChatOptions / appendJsonConstraint / appendStopConstraint). + * Returns "" when no constraints apply. + */ +function buildCursorOutputConstraints(body: { + max_tokens?: unknown; + max_completion_tokens?: unknown; + stop?: unknown; + response_format?: unknown; +}): string { + const constraints: string[] = []; + + const rawMax = body.max_completion_tokens ?? body.max_tokens; + const maxTokens = typeof rawMax === "number" && Number.isFinite(rawMax) ? Math.floor(rawMax) : 0; + if (maxTokens > 0) { + constraints.push(`Keep the answer within about ${maxTokens} output tokens.`); + } + + const stop = body.stop; + if (typeof stop === "string" && stop) { + constraints.push(`Do not include any text at or after this stop sequence: ${stop}`); + } else if (Array.isArray(stop) && stop.length) { + constraints.push(`Stop before any of these sequences: ${stop.filter(Boolean).join(", ")}`); + } + + const fmt = body.response_format; + if (isRecordLike(fmt)) { + if (fmt.type === "json_object") { + constraints.push("Return a single valid JSON object and no surrounding prose or code fences."); + } else if (fmt.type === "json_schema") { + const js = isRecordLike(fmt.json_schema) ? fmt.json_schema.schema : fmt.schema; + constraints.push( + `Return only valid JSON (no prose or code fences) matching this schema: ${JSON.stringify(js ?? fmt)}` + ); + } + } + + return constraints.length + ? `\n\nOUTPUT CONSTRAINTS:\n${constraints.map((c) => `- ${c}`).join("\n")}` + : ""; +} + /** * Build the ExecClientMessage frame that responds to a built-in tool request. * Returns null for the request_context handshake (caller handles separately @@ -166,8 +263,21 @@ const debugLog = (...args: unknown[]) => { // Phase 8: max wall-clock time before we give up on the upstream and abort // the stream. Cursor's longest-observed plain chat takes ~90s; tool-using -// turns can be longer. Five minutes is generous but bounded. -const CURSOR_STREAM_TIMEOUT_MS = parseInt(process.env.CURSOR_STREAM_TIMEOUT_MS || "300000", 10); +// turns can be longer. Five minutes is generous but bounded. A malformed env +// value (NaN / non-positive) falls back to the default rather than breaking +// setTimeout. +const CURSOR_STREAM_TIMEOUT_MS = (() => { + const parsed = parseInt(process.env.CURSOR_STREAM_TIMEOUT_MS || "300000", 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : 300000; +})(); + +// Upper bound on a single Connect-RPC frame. The 4-byte length prefix can +// declare up to 4 GiB; a corrupt or hostile upstream could send a huge length +// that forces driveH2's rolling buffer to grow unbounded (OOM) while it waits +// for bytes that never arrive. Real cursor frames are well under 1 MiB +// (largest observed: a ~13 KB KV blob), so 16 MiB is a generous ceiling that +// turns the failure into a clean stream error instead of memory exhaustion. +const CURSOR_MAX_FRAME_BYTES = 16 * 1024 * 1024; type CursorHttpResponse = { status: number; @@ -288,7 +398,12 @@ export function buildCursorUsage(ctx: StreamCtx, body: { messages?: ChatMessage[ } function emitUsage(ctx: StreamCtx, body: { messages?: ChatMessage[] }) { - if (ctx.tokenDelta <= 0 && ctx.totalText.length === 0 && ctx.thinkingText.length === 0) return; + // Always emit a usage chunk on the success path — the OpenAI streaming + // contract is that every completed response carries usage. buildCursorUsage + // already degrades cleanly to prompt-only counts when the model produced no + // text/thinking (e.g. an empty turn), so there's no need to skip it. The + // mid-stream-error path in finalizeSseStream returns before calling this, so + // errored responses still don't get a spurious usage chunk. const usage = buildCursorUsage(ctx, body); const payload = { id: ctx.responseId, @@ -476,6 +591,12 @@ export function processFrame( // Cursor short-circuits turn_ended for plain chats — kv_server_message // after text means the model finished and the server is saving the // turn. Phase 8 keeps both signals as defense-in-depth. + // + // Safe vs tool calls: when the model invokes a tool, the exec_mcp event + // always arrives at or before this kv checkpoint (verified across many + // live composer-2.5 trials — a tool call never follows kv_after_text), so + // endReason is already "tool_calls" by the time we get here. Ending on + // kv_after_text therefore never truncates a pending tool call. ctx.kvAfterTextSeen = true; ctx.endReason = "kv_after_text"; } @@ -531,18 +652,86 @@ export class CursorExecutor extends BaseExecutor { * root_prompt_messages_json semantically — verified end-to-end with * wire-tap captures. */ - private buildRequest( - model: string, - body: { messages?: ChatMessage[]; tools?: unknown; conversation_id?: string } - ): { body: Uint8Array; blobStore: Map } { + /** + * Assemble the user text + resolved tools shared by the sync (transformRequest) + * and async (buildRequest) request builders. Image resolution is intentionally + * NOT done here — it's async and only the cold-path buildRequest needs it. + */ + private assembleTextAndTools(body: { + messages?: ChatMessage[]; + tools?: unknown; + tool_choice?: unknown; + max_tokens?: unknown; + max_completion_tokens?: unknown; + stop?: unknown; + response_format?: unknown; + }): { userText: string; tools: OpenAITool[] | undefined } { const messages: ChatMessage[] = body.messages || []; - const tools: OpenAITool[] | undefined = Array.isArray(body.tools) + const declaredTools: OpenAITool[] | undefined = Array.isArray(body.tools) ? (body.tools as OpenAITool[]) : undefined; + // tool_choice:"none" means "do not call any tool" — honor it by advertising + // no tools at all (matches OpenAI semantics; composer-api does the same). + const tools = body.tool_choice === "none" ? undefined : declaredTools; // flattenMessages prepends any role:"system" messages into the user - // text (proven path that cursor's models honor). - const userText = flattenMessages(messages); + // text (proven path that cursor's models honor). Image parts in the content + // are ignored here (they carry no text) and resolved separately. + let userText = flattenMessages(messages); + + // When the request declares tools, prepend the tool-commit directive so + // composer-2.5 reliably invokes them instead of narrating intent and + // stopping. Measured live: tool-call rate ~53% → ~88% with the directive. + // tool_choice "required"/specific-function add a forcing line on top. + // Default-on; set CURSOR_TOOL_DIRECTIVE=0 to opt out. See TOOL_COMMIT_DIRECTIVE. + if (tools && tools.length > 0 && process.env.CURSOR_TOOL_DIRECTIVE !== "0") { + userText = `${TOOL_COMMIT_DIRECTIVE}${toolChoiceDirectiveLine(body.tool_choice)}\n\n${userText}`; + } + + // Surface OpenAI output params cursor ignores natively (response_format / + // max_tokens / stop) as trailing prompt constraints. + userText += buildCursorOutputConstraints(body); + + return { userText, tools }; + } + + /** + * Resolve any OpenAI image_url parts in the request's user messages into + * inlined cursor images. Returns undefined when the request carries no + * images (keeps the request byte-identical to the text-only path). Throws + * CursorImageError on invalid / oversized / SSRF-blocked input. + */ + private async resolveRequestImages(body: { + messages?: ChatMessage[]; + }): Promise { + const messages: ChatMessage[] = body.messages || []; + const imageUrls: string[] = []; + for (const m of messages) { + // Images only ride on user turns (the openai-to-cursor translator keeps + // them only there). System/assistant/tool turns carry no vision input. + if (m.role === "user") { + for (const u of extractImageUrls(m.content)) imageUrls.push(u); + } + } + if (imageUrls.length === 0) return undefined; + return resolveCursorImages(imageUrls); + } + + private async buildRequest( + model: string, + body: { + messages?: ChatMessage[]; + tools?: unknown; + tool_choice?: unknown; + conversation_id?: string; + max_tokens?: unknown; + max_completion_tokens?: unknown; + stop?: unknown; + response_format?: unknown; + } + ): Promise<{ body: Uint8Array; blobStore: Map }> { + const { userText, tools } = this.assembleTextAndTools(body); + const images = await this.resolveRequestImages(body); const blobStore = new Map(); const requestBody = buildAgentRequestBody({ @@ -551,12 +740,23 @@ export class CursorExecutor extends BaseExecutor { conversationId: body.conversation_id, tools, blobStore, + images, }); return { body: requestBody, blobStore }; } transformRequest(model, body, _stream, _credentials) { - return this.buildRequest(model, body).body; + // Sync interface method (not used by cursor's own execute() path, which + // uses the async buildRequest). Text-only — image resolution is async. + const { userText, tools } = this.assembleTextAndTools(body); + const blobStore = new Map(); + return buildAgentRequestBody({ + modelId: model, + userText, + conversationId: body.conversation_id, + tools, + blobStore, + }); } // ─── h2 lifecycle: open + drive (Phase 4 streaming refactor) ───────────── @@ -670,7 +870,22 @@ export class CursorExecutor extends BaseExecutor { // Bidirectional streaming: write the init message but DO NOT send // END_STREAM — cursor's server stops responding once we close our side. - req.write(body); + // Guard the write like every h2Req.write in processFrame: a synchronous + // failure here (e.g. stream already torn down) would otherwise leave the + // request hung until the safety timeout instead of failing fast. + try { + req.write(body); + } catch (err) { + if (!resolved) { + resolved = true; + if (signal) signal.removeEventListener("abort", onAbort); + try { + req.close(); + client.close(); + } catch {} + reject(err instanceof Error ? err : new Error(String(err))); + } + } }); } @@ -769,6 +984,14 @@ export class CursorExecutor extends BaseExecutor { let pos = 0; while (!settled && pos + 5 <= buf.length) { const length = buf.readUInt32BE(pos + 1); + if (length > CURSOR_MAX_FRAME_BYTES) { + // Refuse to buffer an implausibly large frame — fail fast instead + // of letting the rolling buffer grow toward OOM. + settled = true; + teardown(); + reject(new Error(`cursor-agent frame too large (${length} bytes)`)); + return; + } if (pos + 5 + length > buf.length) break; // partial frame; wait const flag = buf[pos]; const raw = buf.subarray(pos + 5, pos + 5 + length); @@ -927,8 +1150,30 @@ export class CursorExecutor extends BaseExecutor { if (!session) { // Cold path: open fresh h2 stream with the full message history // flattened into UserText (Phase 6 flattenMessages handles role:"tool" - // and assistant.tool_calls). - const built = this.buildRequest(model, body); + // and assistant.tool_calls). buildRequest also resolves any image_url + // parts (base64 / remote) into inlined cursor images. + let built; + try { + built = await this.buildRequest(model, body); + } catch (err) { + // Image resolution failures (invalid / oversized / SSRF-blocked) are + // client errors — return a sanitized 400 rather than a 500. + if (err instanceof CursorImageError) { + return { + response: buildErrorResponse(err.status, err.message, "invalid_request_error"), + url, + headers, + transformedBody: body, + }; + } + const message = err instanceof Error ? err.message : String(err); + return { + response: buildErrorResponse(HTTP_STATUS.SERVER_ERROR, message, "connection_error"), + url, + headers, + transformedBody: body, + }; + } blobStore = built.blobStore; let opened; try { diff --git a/open-sse/services/cursorSessionManager.ts b/open-sse/services/cursorSessionManager.ts index 6301374b92..95af2b491b 100644 --- a/open-sse/services/cursorSessionManager.ts +++ b/open-sse/services/cursorSessionManager.ts @@ -125,6 +125,9 @@ export class CursorSessionManager { try { session.h2Client.close(); } catch {} + // Drop any unanswered tool-call mappings so a closed session doesn't pin + // their (small) entries for the lifetime of the lingering object. + session.pendingToolCalls.clear(); this.sessions.delete(session.conversationId); } diff --git a/open-sse/translator/request/openai-to-cursor.ts b/open-sse/translator/request/openai-to-cursor.ts index 16f759a0ec..2c488c4618 100644 --- a/open-sse/translator/request/openai-to-cursor.ts +++ b/open-sse/translator/request/openai-to-cursor.ts @@ -12,6 +12,19 @@ import { FORMATS } from "../formats.ts"; type TextPart = { type?: string; text?: string }; type ToolUsePart = { type?: string; id?: string; name?: string; input?: unknown }; type ToolResultPart = { type?: string; tool_use_id?: string; content?: unknown }; +type ImagePart = { type?: string; image_url?: string | { url?: string } }; + +/** + * Pull the URL string out of an OpenAI `image_url` content part. Accepts both + * the canonical `{ image_url: { url } }` and the shorthand `{ image_url: "..." }`. + * Returns "" when no usable url is present. + */ +function extractImageUrl(part: ImagePart): string { + const iu = part.image_url; + if (typeof iu === "string") return iu; + if (iu && typeof iu === "object" && typeof iu.url === "string") return iu.url; + return ""; +} function normalizeToolCallId(id: unknown): string { return typeof id === "string" ? id.split("\n")[0] : ""; @@ -106,7 +119,11 @@ function convertMessages(messages) { if (msg.role === "user" || msg.role === "assistant") { if (msg.role === "user" && Array.isArray(msg.content)) { const parts: string[] = []; - for (const block of msg.content as Array) { + // Preserve vision input: image_url parts are kept (the cursor executor + // inlines them into the request — see resolveCursorImages). Without + // this they'd be silently dropped here and never reach a vision model. + const imageParts: Array<{ type: "image_url"; image_url: { url: string } }> = []; + for (const block of msg.content as Array) { if (!block || typeof block !== "object") continue; if (block.type === "text") { if (typeof (block as TextPart).text === "string") { @@ -114,6 +131,11 @@ function convertMessages(messages) { } continue; } + if (block.type === "image_url") { + const url = extractImageUrl(block as ImagePart); + if (url) imageParts.push({ type: "image_url", image_url: { url } }); + continue; + } if (block.type === "tool_result") { const tr = block as ToolResultPart; const toolCallId = tr.tool_use_id || ""; @@ -126,7 +148,19 @@ function convertMessages(messages) { } } const joined = parts.filter(Boolean).join("\n"); - if (joined) result.push({ role: "user", content: joined }); + if (imageParts.length > 0) { + // Emit an OpenAI content array so the executor sees both the text + // (via flattenMessages) and the images (via extractImageUrls). A + // leading text part keeps text extraction unchanged. + const contentArr: Array< + { type: "text"; text: string } | { type: "image_url"; image_url: { url: string } } + > = []; + if (joined) contentArr.push({ type: "text", text: joined }); + contentArr.push(...imageParts); + result.push({ role: "user", content: contentArr }); + } else if (joined) { + result.push({ role: "user", content: joined }); + } continue; } diff --git a/open-sse/utils/cursorAgentProtobuf.ts b/open-sse/utils/cursorAgentProtobuf.ts index 5fcf0710b2..5e48afebe2 100644 --- a/open-sse/utils/cursorAgentProtobuf.ts +++ b/open-sse/utils/cursorAgentProtobuf.ts @@ -43,6 +43,25 @@ const UM_MESSAGE_ID = 2; // UserMessage.message_id const UM_SELECTED_CONTEXT = 3; // UserMessage.selected_context (empty placeholder required) const UM_MODE = 4; // UserMessage.mode (cursor-agent sends 1) +// ─── Vision input (image) field numbers ──────────────────────────────────── +// Pinned from cursor-agent's agent.v1 protobuf descriptor (bundle version +// 2026.06.02-8c11d9f, cross-checked against composer-api's older-endpoint +// encoder for shape). Images attach to the current UserMessage through its +// selected_context (field 3): UserMessage.selected_context is a SelectedContext +// whose `selected_images` (field 1) is a repeated SelectedImage. Each +// SelectedImage carries the raw bytes inline in its `data_or_blob_id` oneof +// (the `data` case, field 8) — cursor-agent's CLI instead sends a local file +// `path`, which a proxy cannot use, so we inline the bytes like composer-api. +const SC_SELECTED_IMAGES = 1; // SelectedContext.selected_images [repeated SelectedImage] + +const SI_UUID = 2; // SelectedImage.uuid +const SI_DIMENSION = 4; // SelectedImage.dimension (SelectedImage.Dimension) +const SI_MIME_TYPE = 7; // SelectedImage.mime_type +const SI_DATA = 8; // SelectedImage.data (oneof data_or_blob_id) — inline image bytes + +const DIM_WIDTH = 1; // SelectedImage.Dimension.width (int32) +const DIM_HEIGHT = 2; // SelectedImage.Dimension.height (int32) + const RM_MODEL_ID = 1; // RequestedModel.model_id const RM_PARAMETERS = 3; // RequestedModel.parameters [repeated] @@ -266,6 +285,26 @@ type Field = | { fieldNumber: number; wireType: 0; varint: bigint } | { fieldNumber: number; wireType: 2; bytes: Buffer }; +/** + * Validate a length-delimited field's declared length against the bytes that + * actually remain in the buffer. Cursor's frames are well-formed, but a + * corrupted or hostile upstream could declare a length that overruns the + * buffer; without this guard `Buffer.subarray` silently clamps to EOF and a + * truncated tool argument (or any nested message) is decoded as empty/partial + * data instead of being recognized as malformed. Throwing lets the caller — + * `processFrame`, wrapped in driveH2's per-frame try/catch — skip the bad + * frame rather than act on corrupted fields. Also rejects absurd lengths that + * would not fit a JS safe integer. + */ +function checkedLen(len: bigint, pos: number, buf: Buffer): number { + if (len < 0n || len > BigInt(buf.length - pos)) { + throw new Error( + `length-delimited field overruns buffer (len=${len}, remaining=${buf.length - pos})` + ); + } + return Number(len); +} + function decodeFields(buf: Buffer): Field[] { const fields: Field[] = []; let pos = 0; @@ -281,7 +320,7 @@ function decodeFields(buf: Buffer): Field[] { } else if (wireType === WT_LEN) { const [len, np2] = decodeVarint(buf, pos); pos = np2; - const lenN = Number(len); + const lenN = checkedLen(len, pos, buf); fields.push({ fieldNumber, wireType: 2, bytes: buf.subarray(pos, pos + lenN) }); pos += lenN; } else if (wireType === 5) { @@ -328,30 +367,57 @@ export function* iterateConnectFrames(stream: Buffer): Generator { // ─── Model id translation ────────────────────────────────────────────────── +/** + * Canonicalize common spelling variants of cursor's composer model ids to the + * exact ids cursor's server accepts. Without this, an off-by-a-character id + * (composer-2-5, composer-2.5-sdk, composer-latest, or an empty model) reaches + * cursor verbatim and is rejected. Only these known-equivalent spellings are + * remapped (case-insensitively); every other id — including the canonical + * composer-2.5/composer-2.5-fast and all claude, gpt, and gemini ids — passes + * through unchanged, so existing behavior is preserved exactly. + */ +const CURSOR_MODEL_ALIASES: Record = { + "": "composer-2.5", + "composer-2-5": "composer-2.5", + "composer-2.5-sdk": "composer-2.5", + "composer-latest": "composer-2.5", + "composer-2-5-fast": "composer-2.5-fast", + "composer-2.5-sdk-fast": "composer-2.5-fast", + "composer-latest-fast": "composer-2.5-fast", +}; + +export function normalizeCursorModelId(modelId: string): string { + const id = (modelId ?? "").trim(); + const alias = CURSOR_MODEL_ALIASES[id.toLowerCase()]; + return alias ?? id; +} + /** * cursor-agent rewrites model ids before putting them on the wire: * "auto" → RequestedModel { model_id: "default" } * "composer-2-fast" → RequestedModel { model_id: "composer-2", * parameters: [{id: "fast", value: "true"}] } * - * Other ids (e.g. "claude-4.6-sonnet-medium") are passed through verbatim. + * Other ids (e.g. "claude-4.6-sonnet-medium") are passed through verbatim + * after spelling-variant normalization (see normalizeCursorModelId). */ export function resolveRequestedModel(modelId: string): { modelId: string; parameters: Array<{ id: string; value: string }>; } { - if (modelId === "auto") { + const normalized = normalizeCursorModelId(modelId); + if (normalized === "auto") { return { modelId: "default", parameters: [] }; } // Strip the "-fast" suffix and surface it as a parameter — only the composer // family observably needs this split today, but the protocol field is generic. - if (modelId.startsWith("composer-") && modelId.endsWith("-fast")) { + if (normalized.startsWith("composer-") && normalized.endsWith("-fast")) { return { - modelId: modelId.slice(0, -"-fast".length), + modelId: normalized.slice(0, -"-fast".length), parameters: [{ id: "fast", value: "true" }], }; } - return { modelId, parameters: [] }; + return { modelId: normalized, parameters: [] }; } // ─── Request encoder ─────────────────────────────────────────────────────── @@ -386,8 +452,59 @@ export type AgentRunInput = { // which the executor's processFrame replies to with the stored bytes. systemPrompt?: string; blobStore?: Map; + // Vision input: images attached to the current user turn. Encoded inline as + // SelectedContext.selected_images[] (see encodeSelectedImageBody). Empty / + // undefined keeps the request byte-identical to the text-only path. + images?: EncodedImage[]; }; +/** + * A resolved image ready to embed in a cursor request. `data` is the raw + * decoded image bytes (already SSRF-checked / size-capped by the executor's + * resolveCursorImages helper). `mimeType` (e.g. "image/png") helps cursor + * decode the inline bytes; `width`/`height` populate the optional Dimension + * sub-message when cheaply known; `uuid` is a stable per-image id. + */ +export type EncodedImage = { + data: Buffer; + mimeType?: string; + width?: number; + height?: number; + uuid: string; +}; + +/** + * Encode the body of a SelectedImage message (no outer field tag — the caller + * wraps it via encodeMessage(SC_SELECTED_IMAGES, [body])). Sets the inline + * `data` oneof case plus uuid, optional dimension, and mime_type. Fields are + * written in ascending field-number order (canonical protobuf layout). + */ +export function encodeSelectedImageBody(img: EncodedImage): Buffer { + const parts: Buffer[] = [encodeString(SI_UUID, img.uuid)]; + if ( + typeof img.width === "number" && + typeof img.height === "number" && + Number.isFinite(img.width) && + Number.isFinite(img.height) && + img.width > 0 && + img.height > 0 + ) { + parts.push( + encodeMessage(SI_DIMENSION, [ + encodeUInt32Field(DIM_WIDTH, Math.floor(img.width)), + encodeUInt32Field(DIM_HEIGHT, Math.floor(img.height)), + ]) + ); + } + if (img.mimeType) { + parts.push(encodeString(SI_MIME_TYPE, img.mimeType)); + } + // data_or_blob_id oneof = data (inline bytes) — field 8, written last to + // keep ascending field order. + parts.push(encodeBytes(SI_DATA, img.data)); + return Buffer.concat(parts); +} + /** * Convert OpenAI tool definitions to cursor McpToolDefinition bodies. Used * both by the AgentRunRequest builder (mcp_tools field) and by the request @@ -412,14 +529,26 @@ export function encodeAgentRunRequest(input: AgentRunInput): Buffer { const messageId = input.messageId || crypto.randomUUID(); const { modelId, parameters } = resolveRequestedModel(input.modelId); - // UserMessage { text, message_id, selected_context: empty, mode=1 }. + // UserMessage { text, message_id, selected_context, mode=1 }. + // selected_context is normally an empty placeholder (required by the server + // even when empty — see below), but when the turn carries vision input we + // populate its selected_images[] with the inline-encoded images. The + // empty-images path produces byte-identical output to the text-only request. + const selectedContextParts: Buffer[] = []; + if (input.images && input.images.length > 0) { + for (const img of input.images) { + selectedContextParts.push( + encodeMessage(SC_SELECTED_IMAGES, [encodeSelectedImageBody(img)]) + ); + } + } // The empty selected_context placeholder and mode=1 match cursor-agent's // wire format; without them the server accepts the request but never // streams a response. const userMessage = encodeMessage(UMA_USER_MESSAGE, [ encodeString(UM_TEXT, input.userText), encodeString(UM_MESSAGE_ID, messageId), - encodeMessage(UM_SELECTED_CONTEXT, []), + encodeMessage(UM_SELECTED_CONTEXT, selectedContextParts), Buffer.concat([encodeTag(UM_MODE, WT_VARINT), encodeVarint(1)]), ]); // UserMessageAction { user_message } @@ -1165,7 +1294,7 @@ export function decodeProtobufValue(buf: Buffer): unknown { if (wireType === WT_LEN) { const [len, np2] = decodeVarint(buf, pos); pos = np2; - const lenN = Number(len); + const lenN = checkedLen(len, pos, buf); const value = buf.subarray(pos, pos + lenN).toString("utf8"); pos += lenN; return value; @@ -1184,7 +1313,7 @@ export function decodeProtobufValue(buf: Buffer): unknown { if (wireType === WT_LEN) { const [len, np2] = decodeVarint(buf, pos); pos = np2; - const lenN = Number(len); + const lenN = checkedLen(len, pos, buf); const inner = buf.subarray(pos, pos + lenN); pos += lenN; return decodeProtobufStruct(inner); @@ -1195,7 +1324,7 @@ export function decodeProtobufValue(buf: Buffer): unknown { if (wireType === WT_LEN) { const [len, np2] = decodeVarint(buf, pos); pos = np2; - const lenN = Number(len); + const lenN = checkedLen(len, pos, buf); const inner = buf.subarray(pos, pos + lenN); pos += lenN; return decodeProtobufList(inner); diff --git a/open-sse/utils/cursorImages.ts b/open-sse/utils/cursorImages.ts new file mode 100644 index 0000000000..29e669f57e --- /dev/null +++ b/open-sse/utils/cursorImages.ts @@ -0,0 +1,354 @@ +/** + * Image resolution + security for Cursor vision input. + * + * Turns OpenAI `image_url` parts (base64 `data:` URIs or remote `http(s)` + * URLs) into decoded bytes ready to inline into a cursor SelectedImage + * (see ../utils/cursorAgentProtobuf.ts::encodeSelectedImageBody). + * + * Security (OmniRoute hard rules): + * - SSRF: remote fetches go through the repo's canonical outbound guard + * (`parseAndValidatePublicUrl`), which rejects non-http(s) schemes, + * embedded credentials, localhost, link-local, private/CGNAT ranges, and + * cloud-metadata hostnames. Client-supplied image URLs are always held to + * the strict public-only policy (never gated by the private-URL toggle that + * admin-configured provider URLs use). + * - Size cap: each image must decode to <= 1 MiB (matches composer-api). + * Enforced both before base64 decode (cheap pre-check) and while streaming + * a remote body (so a hostile server can't stream gigabytes). + * - Content type: data URIs and URL responses must be `image/*`. + * - Errors throw `CursorImageError` with a clean, path-free message; the + * executor routes it through the sanitized 400 path (hard rule #12). + */ + +import crypto from "node:crypto"; +import dns from "node:dns"; +import { isIP } from "node:net"; +import { + parseAndValidatePublicUrl, + isPrivateHost, + OutboundUrlGuardError, +} from "@/shared/network/outboundUrlGuard"; +import type { EncodedImage } from "./cursorAgentProtobuf.ts"; + +// 1 MiB per image — matches composer-api's MAX_CURSOR_IMAGE_BYTES. Large +// enough for a typical screenshot, small enough to bound request size and +// memory. +export const MAX_CURSOR_IMAGE_BYTES = 1024 * 1024; + +// Upper bound on the number of images per request. Each image triggers (at +// most) one remote fetch, so an unbounded count is a DoS vector; 12 is well +// above any realistic vision prompt. +export const MAX_CURSOR_IMAGES = 12; + +// Wall-clock cap for a single remote image fetch. A malformed env value +// (NaN / non-positive) falls back to the default rather than breaking setTimeout. +const IMAGE_FETCH_TIMEOUT_MS = (() => { + const parsed = parseInt(process.env.CURSOR_IMAGE_FETCH_TIMEOUT_MS || "15000", 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : 15000; +})(); + +// Bound on how many redirects fetchImageBytes will follow (each re-validated +// against the SSRF guard before the next hop). +const MAX_IMAGE_REDIRECTS = 3; + +/** + * A 400-class error carrying a clean, non-sensitive message. The executor + * catches it and emits a sanitized error response. + */ +export class CursorImageError extends Error { + status: number; + constructor(message: string, status = 400) { + super(message); + this.name = "CursorImageError"; + this.status = status; + } +} + +function decodeDataUrl(url: string): { data: Buffer; mimeType: string } { + // data:[][;base64], + const comma = url.indexOf(","); + if (comma < 0) { + throw new CursorImageError("Image data URL is malformed."); + } + const header = url.slice(5, comma); // strip leading "data:" + const payload = url.slice(comma + 1); + const isBase64 = /;base64/i.test(header); + const mimeType = (header.split(";")[0] || "").trim().toLowerCase() || "application/octet-stream"; + + if (!mimeType.startsWith("image/")) { + throw new CursorImageError("Image data URL must have an image/* media type."); + } + if (!isBase64) { + // Non-base64 data URLs (percent-encoded) are not a real image transport; + // reject rather than guess. + throw new CursorImageError("Image data URL must be base64-encoded."); + } + + // Reject on the raw payload length BEFORE the regex/normalize pass, so an + // arbitrarily large data URL can't burn CPU on the whitespace strip. Base64 + // expands ~4:3, so 2x the byte cap is a safe upper bound on the encoded text. + if (payload.length > MAX_CURSOR_IMAGE_BYTES * 2) { + throw new CursorImageError("Image input is too large (max 1 MiB). Resize and retry."); + } + + const normalized = payload.replace(/\s/g, ""); + // Cheap pre-check: 4 base64 chars -> 3 bytes. Reject obviously oversized + // payloads before allocating the decode buffer. + if (Math.floor((normalized.length * 3) / 4) > MAX_CURSOR_IMAGE_BYTES) { + throw new CursorImageError("Image input is too large (max 1 MiB). Resize and retry."); + } + + let data: Buffer; + try { + data = Buffer.from(normalized, "base64"); + } catch { + throw new CursorImageError("Image data URL contains invalid base64 data."); + } + // Buffer.from(base64) silently drops invalid trailing chars; guard against a + // payload that decoded to nothing despite being non-empty. + if (normalized.length > 0 && data.length === 0) { + throw new CursorImageError("Image data URL contains invalid base64 data."); + } + return { data, mimeType }; +} + +// Validate a URL through the SSRF guard, mapping guard errors to clean, +// non-sensitive CursorImageErrors (no URL echoed back). +function validatePublicImageUrl(url: string): URL { + try { + return parseAndValidatePublicUrl(url); + } catch (err) { + if (err instanceof OutboundUrlGuardError) { + throw new CursorImageError( + err.code === "OUTBOUND_URL_INVALID" + ? "Image URL is invalid or uses an unsupported scheme." + : "Image URL points to a blocked address." + ); + } + throw new CursorImageError("Image URL is invalid."); + } +} + +/** + * Throw if any of the resolved addresses falls in a private / link-local / + * loopback / CGNAT / metadata range. Exported for unit testing the IP gate + * without going through DNS. + */ +export function assertResolvedAddressesPublic(addresses: string[]): void { + for (const addr of addresses) { + if (isPrivateHost(addr)) { + throw new CursorImageError("Image URL points to a blocked address."); + } + } +} + +/** + * Defence-in-depth against DNS-rebinding SSRF: `parseAndValidatePublicUrl` + * only checks the hostname *string*, so a public-looking host that resolves to + * a private/metadata IP would otherwise be fetched. Resolve the host and + * reject if ANY answer is private. IP literals are skipped (already validated + * by the guard above). This narrows — but doesn't fully eliminate — the + * TOCTOU window between our resolution and fetch's own; a connection-time IP + * filter (e.g. ssrf-req-filter) on the shared outbound guard would close it + * for every caller. + */ +async function assertHostnameResolvesPublic(hostname: string): Promise { + const bare = + hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname; + if (isIP(bare)) return; // IP literal — already checked by the URL guard. + let resolved: Array<{ address: string }>; + try { + resolved = await dns.promises.lookup(bare, { all: true }); + } catch { + throw new CursorImageError("Image URL host could not be resolved."); + } + assertResolvedAddressesPublic(resolved.map((r) => r.address)); +} + +async function fetchImageBytes(url: string): Promise<{ data: Buffer; mimeType: string }> { + // Follow redirects MANUALLY and re-validate every hop through the SSRF guard. + // `fetch` follows redirects by default, so validating only the initial URL + // would let a public host 30x-redirect to a private/link-local address and + // bypass the guard. Each Location is resolved + re-checked before we fetch it. + let currentUrl = url; + for (let hop = 0; hop <= MAX_IMAGE_REDIRECTS; hop++) { + const parsed = validatePublicImageUrl(currentUrl); + // Resolve + IP-check the host (DNS-rebinding defence) before connecting. + await assertHostnameResolvesPublic(parsed.hostname); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), IMAGE_FETCH_TIMEOUT_MS); + let response: Response; + try { + response = await fetch(parsed.toString(), { + method: "GET", + signal: controller.signal, + redirect: "manual", + }); + } catch { + clearTimeout(timer); + throw new CursorImageError("Could not fetch the image URL."); + } + try { + // Manual redirect: resolve Location against the current URL and loop so + // the next hop is re-validated by the SSRF guard. + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get("location"); + if (!location) { + throw new CursorImageError("Image URL redirect is missing a destination."); + } + try { + currentUrl = new URL(location, parsed.toString()).toString(); + } catch { + throw new CursorImageError("Image URL redirect destination is invalid."); + } + continue; + } + + if (!response.ok) { + throw new CursorImageError(`Could not fetch the image URL (status ${response.status}).`); + } + const contentType = (response.headers.get("content-type") || "").toLowerCase(); + const mimeType = contentType.split(";")[0].trim(); + if (!mimeType.startsWith("image/")) { + throw new CursorImageError("Image URL did not return an image content type."); + } + // Reject early on an oversized Content-Length, then still cap during read + // (the header is advisory / may be absent). + const declaredLen = Number(response.headers.get("content-length") || "0"); + if (Number.isFinite(declaredLen) && declaredLen > MAX_CURSOR_IMAGE_BYTES) { + throw new CursorImageError("Image input is too large (max 1 MiB). Resize and retry."); + } + const data = await readCapped(response, MAX_CURSOR_IMAGE_BYTES); + return { data, mimeType }; + } finally { + clearTimeout(timer); + } + } + throw new CursorImageError("Image URL has too many redirects."); +} + +/** + * Read a fetch Response body into a Buffer, aborting as soon as the + * accumulated size exceeds `cap`. Consumes the body incrementally — as an + * async iterable (Node Readable streams and Web Streams both support this) or + * via a web ReadableStream reader — so an oversized body is rejected mid-read + * rather than fully buffered. The uncapped arrayBuffer() path is only a last + * resort for exotic body shapes, and is still cap-checked afterwards. + */ +async function readCapped(response: Response, cap: number): Promise { + const body = response.body as + | (AsyncIterable & { getReader?: () => ReadableStreamDefaultReader }) + | null; + if (!body) { + return Buffer.alloc(0); + } + + const chunks: Buffer[] = []; + let total = 0; + const pushCapped = (chunk: Uint8Array) => { + total += chunk.byteLength; + if (total > cap) { + throw new CursorImageError("Image input is too large (max 1 MiB). Resize and retry."); + } + chunks.push(Buffer.from(chunk)); + }; + + // Preferred: async iteration (works for Node Readable + Web Streams). + if (typeof (body as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === "function") { + for await (const chunk of body) { + pushCapped(chunk as Uint8Array); + } + return Buffer.concat(chunks, total); + } + + // Fallback: web ReadableStream reader. + if (typeof body.getReader === "function") { + const reader = body.getReader(); + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (value) pushCapped(value); + } + } finally { + try { + await reader.cancel(); + } catch { + /* already closed */ + } + } + return Buffer.concat(chunks, total); + } + + // Last resort: buffer then cap-check (only exotic non-stream bodies). + const buf = Buffer.from(await response.arrayBuffer()); + if (buf.length > cap) { + throw new CursorImageError("Image input is too large (max 1 MiB). Resize and retry."); + } + return buf; +} + +/** + * Resolve OpenAI `image_url` URLs (data: or http(s):) into EncodedImage[] + * ready to inline into a cursor request. Each image gets a stable random uuid. + * Throws CursorImageError (clean message, sanitizable) on any invalid / + * oversized / blocked input. + */ +export async function resolveCursorImages(imageUrls: string[]): Promise { + if (imageUrls.length > MAX_CURSOR_IMAGES) { + throw new CursorImageError( + `Too many images in one request (max ${MAX_CURSOR_IMAGES}).` + ); + } + const out: EncodedImage[] = []; + for (const url of imageUrls) { + if (typeof url !== "string" || !url) { + throw new CursorImageError("Image URL is missing."); + } + // The data: scheme is case-insensitive (RFC 2397); match it that way but + // pass the original (un-lowercased) url so the base64 payload is preserved. + const { data, mimeType } = url.toLowerCase().startsWith("data:") + ? decodeDataUrl(url) + : await fetchImageBytes(url); + if (!data.length) { + throw new CursorImageError("Image input is empty."); + } + if (data.length > MAX_CURSOR_IMAGE_BYTES) { + throw new CursorImageError("Image input is too large (max 1 MiB). Resize and retry."); + } + out.push({ data, mimeType, uuid: crypto.randomUUID() }); + } + return out; +} + +/** + * Extract image_url URLs from an OpenAI-shaped message content array. + * Returns the raw url strings (data: or http(s):) in order. Non-image parts + * are ignored. A plain string content has no images. + */ +export function extractImageUrls( + content: unknown +): string[] { + if (!Array.isArray(content)) return []; + const urls: string[] = []; + for (const part of content) { + if ( + part && + typeof part === "object" && + (part as { type?: unknown }).type === "image_url" + ) { + const imageUrl = (part as { image_url?: unknown }).image_url; + if (typeof imageUrl === "string") { + urls.push(imageUrl); + } else if ( + imageUrl && + typeof imageUrl === "object" && + typeof (imageUrl as { url?: unknown }).url === "string" + ) { + urls.push((imageUrl as { url: string }).url); + } + } + } + return urls; +} diff --git a/open-sse/utils/cursorVersionDetector.ts b/open-sse/utils/cursorVersionDetector.ts index 87c4f1ff89..931f77622b 100644 --- a/open-sse/utils/cursorVersionDetector.ts +++ b/open-sse/utils/cursorVersionDetector.ts @@ -12,7 +12,13 @@ import { createRequire } from "module"; const CACHE_TTL_MS = 60 * 60 * 1000; const DB_KEY = "cursorupdate.lastUpdatedAndShown.version"; -const FALLBACK_VERSION = "3.3"; +/** + * Version reported when the Cursor IDE state DB is unavailable (the common + * case for a headless OmniRoute deployment). Kept in sync with + * `CURSOR_REGISTRY_VERSION` in providerHeaderProfiles.ts. Exported so tests + * assert against the single source of truth instead of a drifting literal. + */ +export const FALLBACK_VERSION = "3.3"; let cachedVersion: string | null = null; let cachedAt = 0; diff --git a/tests/integration/cursor-e2e.test.ts b/tests/integration/cursor-e2e.test.ts index a467a2594f..582b961499 100644 --- a/tests/integration/cursor-e2e.test.ts +++ b/tests/integration/cursor-e2e.test.ts @@ -3,13 +3,20 @@ * * Skipped unless `CURSOR_E2E_TOKEN` env var is set. Exercises the full * OpenAI-compatible flow against cursor's real `agent.v1.AgentService/Run` - * endpoint: + * endpoint, across both auto/claude (tests 1-4) and composer-2.5 (tests 5-9): * - * 1. Single-turn chat with system prompt - * 2. Tool-use round trip (request → tool_calls → role:"tool" follow-up) - * 3. Streaming SSE incremental delivery - * 4. Inline-session reuse across two consecutive calls - * 5. Cold-resume fallback when session is missing/evicted + * 1. Single-turn plain chat + * 2. System prompt biasing + * 3. Tool-use single-turn (request → tool_calls) + * 4. Streaming SSE incremental delivery + * 5. composer-2.5 plain chat (+ usage present) + * 6. composer-2.5 reasoning surfaced as reasoning_content (no marker leakage) + * 7. composer-2.5 multi-turn tool round-trip (inline h2 session reuse) + * 8. composer-2.5 cold-resume fallback (no live session) + * 9. composer-2.5 streaming (+ usage chunk) + * + * The composer model id is overridable with CURSOR_E2E_MODEL (e.g. + * composer-2.5-fast). * * To run: * CURSOR_E2E_TOKEN=$(cat ~/.cursor/access-token) \ @@ -21,10 +28,73 @@ import test from "node:test"; import assert from "node:assert/strict"; +import zlib from "node:zlib"; const TOKEN = process.env.CURSOR_E2E_TOKEN; const skipReason = TOKEN ? undefined : "CURSOR_E2E_TOKEN not set"; +// Model used by the composer-specific regression tests below. Override with +// CURSOR_E2E_MODEL to exercise composer-2.5-fast or another id. +const COMPOSER_MODEL = process.env.CURSOR_E2E_MODEL || "composer-2.5"; + +// Vision-capable model for the image tests. composer-2.5 and the claude/gpt +// ids all accept inline images; default to a gpt id, override with +// CURSOR_E2E_VISION_MODEL. A public solid-color image service backs the +// URL-image test (override with CURSOR_E2E_IMAGE_URL). +const VISION_MODEL = process.env.CURSOR_E2E_VISION_MODEL || "gpt-5.2"; +const RED_IMAGE_URL = + process.env.CURSOR_E2E_IMAGE_URL || "https://dummyimage.com/80x80/ff0000/ff0000.png"; + +// Build a valid solid-color PNG (size x size, truecolor) with no deps — used +// to prove a vision model actually reads the inline image bytes. +function solidColorPng(size: number, rgb: [number, number, number]): Buffer { + const crc32 = (buf: Buffer): number => { + let c = ~0; + for (let i = 0; i < buf.length; i++) { + c ^= buf[i]; + for (let k = 0; k < 8; k++) c = c & 1 ? (c >>> 1) ^ 0xedb88320 : c >>> 1; + } + return (~c) >>> 0; + }; + const chunk = (type: string, data: Buffer): Buffer => { + const t = Buffer.from(type, "ascii"); + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length, 0); + const body = Buffer.concat([t, data]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(body), 0); + return Buffer.concat([len, body, crc]); + }; + const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(size, 0); + ihdr.writeUInt32BE(size, 4); + ihdr[8] = 8; // bit depth + ihdr[9] = 2; // color type 2 = truecolor RGB + const row = Buffer.concat([ + Buffer.from([0]), + Buffer.concat(Array.from({ length: size }, () => Buffer.from(rgb))), + ]); + const raw = Buffer.concat(Array.from({ length: size }, () => row)); + const idat = zlib.deflateSync(raw); + return Buffer.concat([sig, chunk("IHDR", ihdr), chunk("IDAT", idat), chunk("IEND", Buffer.alloc(0))]); +} + +const weatherTools = [ + { + type: "function", + function: { + name: "get_weather", + description: "Get current weather for a city", + parameters: { + type: "object", + properties: { city: { type: "string" } }, + required: ["city"], + }, + }, + }, +]; + test( "[cursor-e2e] single-turn plain chat returns assistant text", { skip: skipReason }, @@ -145,3 +215,314 @@ test( assert.match(totalText, /data: \[DONE\]/); } ); + +// ─── composer-2.5 regression coverage ────────────────────────────────────── +// +// The four tests above cover auto/claude single-turn. These add coverage for +// the composer model specifically plus the two highest-value untested paths: +// the multi-turn tool round-trip (inline h2 session reuse) and the cold-resume +// fallback. All were validated end-to-end against the live endpoint. + +test("[cursor-e2e] composer-2.5 plain chat returns assistant text", { skip: skipReason }, async () => { + const { CursorExecutor } = await import("../../open-sse/executors/cursor.ts"); + const exec = new CursorExecutor(); + const result = await exec.execute({ + model: COMPOSER_MODEL, + body: { messages: [{ role: "user", content: "Say only the word PING and nothing else." }] }, + stream: false, + credentials: { accessToken: TOKEN }, + signal: undefined, + log: () => {}, + upstreamExtraHeaders: undefined, + }); + assert.equal(result.response.status, 200); + const json = await result.response.json(); + assert.equal(json.choices[0].finish_reason, "stop"); + assert.match(json.choices[0].message.content, /PING/i); + // Usage is always present on the success path (OpenAI contract). + assert.equal(typeof json.usage?.total_tokens, "number"); +}); + +test( + "[cursor-e2e] composer-2.5 surfaces reasoning as reasoning_content", + { skip: skipReason }, + async () => { + const { CursorExecutor } = await import("../../open-sse/executors/cursor.ts"); + const exec = new CursorExecutor(); + const result = await exec.execute({ + model: COMPOSER_MODEL, + body: { + messages: [ + { role: "user", content: "Think step by step: what is 17 * 23? Then give the answer." }, + ], + }, + stream: false, + credentials: { accessToken: TOKEN }, + signal: undefined, + log: () => {}, + upstreamExtraHeaders: undefined, + }); + assert.equal(result.response.status, 200); + const json = await result.response.json(); + // The final answer is plain text — reasoning must NOT leak control tokens + // (, <|final|>, <|tool_calls_begin|>) into the visible content. + const content = json.choices[0].message.content || ""; + assert.match(content, /391/); + assert.doesNotMatch(content, /<\|?tool_calls_begin|<\/think>|<\|?final\|?>/); + } +); + +test( + "[cursor-e2e] composer-2.5 multi-turn tool round-trip reuses the h2 session", + { skip: skipReason }, + async () => { + const { CursorExecutor } = await import("../../open-sse/executors/cursor.ts"); + const { cursorSessionManager } = await import( + "../../open-sse/services/cursorSessionManager.ts" + ); + const exec = new CursorExecutor(); + const conversationId = `e2e-rt-${Date.now()}`; + + // Turn 1: declare a tool → expect tool_calls + a retained session. + const r1 = await exec.execute({ + model: COMPOSER_MODEL, + body: { + conversation_id: conversationId, + messages: [{ role: "user", content: "What's the weather in Paris? Call get_weather." }], + tools: weatherTools, + }, + stream: false, + credentials: { accessToken: TOKEN }, + signal: undefined, + log: () => {}, + upstreamExtraHeaders: undefined, + }); + const j1 = await r1.response.json(); + assert.equal(j1.choices[0].finish_reason, "tool_calls"); + const toolCall = j1.choices[0].message.tool_calls?.[0]; + assert.ok(toolCall, "expected a tool_call on turn 1"); + assert.equal(toolCall.function.name, "get_weather"); + assert.ok( + cursorSessionManager.has(conversationId), + "session should be retained for inline resume" + ); + + // Turn 2: same conversation_id, append the tool result → final answer. + const r2 = await exec.execute({ + model: COMPOSER_MODEL, + body: { + conversation_id: conversationId, + messages: [ + { role: "user", content: "What's the weather in Paris? Call get_weather." }, + { role: "assistant", content: null, tool_calls: [toolCall] }, + { + role: "tool", + tool_call_id: toolCall.id, + name: "get_weather", + content: '{"temp_c": 19, "condition": "sunny"}', + }, + ], + tools: weatherTools, + }, + stream: false, + credentials: { accessToken: TOKEN }, + signal: undefined, + log: () => {}, + upstreamExtraHeaders: undefined, + }); + const j2 = await r2.response.json(); + assert.equal(r2.response.status, 200); + assert.match(j2.choices[0].message.content || "", /19|sunny/i); + } +); + +test( + "[cursor-e2e] composer-2.5 cold-resume incorporates a tool result without a live session", + { skip: skipReason }, + async () => { + const { CursorExecutor } = await import("../../open-sse/executors/cursor.ts"); + const exec = new CursorExecutor(); + // Brand-new conversation_id with a fabricated prior tool call/result and no + // session ever opened → acquire() misses, exercising the cold-resume path + // (fresh RunRequest with full history flattened into UserText). + const result = await exec.execute({ + model: COMPOSER_MODEL, + body: { + conversation_id: `e2e-cold-${Date.now()}`, + messages: [ + { role: "user", content: "What's the weather in Tokyo? Call get_weather." }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_cold_1", + type: "function", + function: { name: "get_weather", arguments: '{"city":"Tokyo"}' }, + }, + ], + }, + { + role: "tool", + tool_call_id: "call_cold_1", + name: "get_weather", + content: '{"temp_c": 8, "condition": "rainy"}', + }, + ], + tools: weatherTools, + }, + stream: false, + credentials: { accessToken: TOKEN }, + signal: undefined, + log: () => {}, + upstreamExtraHeaders: undefined, + }); + assert.equal(result.response.status, 200); + const json = await result.response.json(); + assert.match(json.choices[0].message.content || "", /8|rainy/i); + } +); + +test( + "[cursor-e2e] composer-2.5 honors response_format json_object", + { skip: skipReason }, + async () => { + const { CursorExecutor } = await import("../../open-sse/executors/cursor.ts"); + const exec = new CursorExecutor(); + const result = await exec.execute({ + model: COMPOSER_MODEL, + body: { + messages: [ + { role: "user", content: "Give me a fake user profile with fields name, age, and city." }, + ], + response_format: { type: "json_object" }, + }, + stream: false, + credentials: { accessToken: TOKEN }, + signal: undefined, + log: () => {}, + upstreamExtraHeaders: undefined, + }); + assert.equal(result.response.status, 200); + const json = await result.response.json(); + const content = (json.choices[0].message.content || "").trim(); + // cursor's agent endpoint has no native response_format; the OUTPUT + // CONSTRAINTS prompt injection is what makes the model return raw JSON. + const parsed = JSON.parse(content); + assert.equal(typeof parsed, "object"); + } +); + +test( + "[cursor-e2e] composer-2.5 streaming delivers incremental chunks", + { skip: skipReason }, + async () => { + const { CursorExecutor } = await import("../../open-sse/executors/cursor.ts"); + const exec = new CursorExecutor(); + const result = await exec.execute({ + model: COMPOSER_MODEL, + body: { messages: [{ role: "user", content: "Count from 1 to 5, one number per line." }] }, + stream: true, + credentials: { accessToken: TOKEN }, + signal: undefined, + log: () => {}, + upstreamExtraHeaders: undefined, + }); + assert.equal(result.response.status, 200); + const reader = (result.response.body as ReadableStream).getReader(); + const decoder = new TextDecoder(); + let chunks = 0; + let sawUsage = false; + let totalText = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const text = decoder.decode(value); + chunks++; + totalText += text; + if (text.includes('"usage"')) sawUsage = true; + } + assert.ok(chunks > 1, `expected multiple chunks; got ${chunks}`); + assert.match(totalText, /data: \[DONE\]/); + assert.ok(sawUsage, "streaming response should include a usage chunk"); + } +); + +test( + "[cursor-e2e] base64 image_url reaches a vision model (sees the color)", + { skip: skipReason }, + async () => { + const { CursorExecutor } = await import("../../open-sse/executors/cursor.ts"); + const exec = new CursorExecutor(); + const png = solidColorPng(64, [255, 0, 0]); // solid red + const dataUri = `data:image/png;base64,${png.toString("base64")}`; + const result = await exec.execute({ + model: VISION_MODEL, + body: { + messages: [ + { + role: "user", + content: [ + { + type: "text", + text: "What single color is this square? Answer with just the color name.", + }, + { type: "image_url", image_url: { url: dataUri } }, + ], + }, + ], + }, + stream: false, + credentials: { accessToken: TOKEN }, + signal: undefined, + log: () => {}, + upstreamExtraHeaders: undefined, + }); + assert.equal(result.response.status, 200); + const json = await result.response.json(); + assert.equal(json.choices[0].finish_reason, "stop"); + assert.match( + json.choices[0].message.content, + /red/i, + `vision model should report red; got: ${json.choices[0].message.content}` + ); + } +); + +test( + "[cursor-e2e] remote image_url is fetched and reaches a vision model", + { skip: skipReason }, + async () => { + const { CursorExecutor } = await import("../../open-sse/executors/cursor.ts"); + const exec = new CursorExecutor(); + const result = await exec.execute({ + model: VISION_MODEL, + body: { + messages: [ + { + role: "user", + content: [ + { + type: "text", + text: "What single color is this square? Answer with just the color name.", + }, + { type: "image_url", image_url: { url: RED_IMAGE_URL } }, + ], + }, + ], + }, + stream: false, + credentials: { accessToken: TOKEN }, + signal: undefined, + log: () => {}, + upstreamExtraHeaders: undefined, + }); + assert.equal(result.response.status, 200); + const json = await result.response.json(); + assert.match( + json.choices[0].message.content, + /red/i, + `vision model should report red; got: ${json.choices[0].message.content}` + ); + } +); diff --git a/tests/unit/cursor-agent-protobuf.test.ts b/tests/unit/cursor-agent-protobuf.test.ts index adb34505ea..cf447f9c01 100644 --- a/tests/unit/cursor-agent-protobuf.test.ts +++ b/tests/unit/cursor-agent-protobuf.test.ts @@ -2,6 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { resolveRequestedModel, + normalizeCursorModelId, encodeAgentRunRequest, buildAgentRequestBody, iterateConnectFrames, @@ -41,6 +42,83 @@ test("resolveRequestedModel maps cursor-agent's client-side aliases", () => { assert.deepEqual(resolveRequestedModel("composer-2"), { modelId: "composer-2", parameters: [] }); }); +test("normalizeCursorModelId canonicalizes composer spelling variants", () => { + // Known-equivalent spellings cursor would otherwise reject. + assert.equal(normalizeCursorModelId("composer-2-5"), "composer-2.5"); + assert.equal(normalizeCursorModelId("composer-2.5-sdk"), "composer-2.5"); + assert.equal(normalizeCursorModelId("composer-latest"), "composer-2.5"); + assert.equal(normalizeCursorModelId("COMPOSER-2-5"), "composer-2.5"); // case-insensitive + assert.equal(normalizeCursorModelId(" composer-latest "), "composer-2.5"); // trimmed + assert.equal(normalizeCursorModelId(""), "composer-2.5"); // empty → default model + assert.equal(normalizeCursorModelId("composer-2-5-fast"), "composer-2.5-fast"); + // Canonical and unrelated ids pass through verbatim (no behavior change). + assert.equal(normalizeCursorModelId("composer-2.5"), "composer-2.5"); + assert.equal(normalizeCursorModelId("composer-2.5-fast"), "composer-2.5-fast"); + assert.equal(normalizeCursorModelId("auto"), "auto"); + assert.equal(normalizeCursorModelId("claude-4.6-sonnet-medium"), "claude-4.6-sonnet-medium"); +}); + +test("resolveRequestedModel normalizes variants then applies auto/-fast rules", () => { + // Variant of composer-2.5 → canonical id, no parameters. + assert.deepEqual(resolveRequestedModel("composer-2-5"), { + modelId: "composer-2.5", + parameters: [], + }); + // Variant of the fast model → split into base id + fast parameter. + assert.deepEqual(resolveRequestedModel("composer-2-5-fast"), { + modelId: "composer-2.5", + parameters: [{ id: "fast", value: "true" }], + }); + // Empty model id resolves to the working default rather than a server reject. + assert.deepEqual(resolveRequestedModel(""), { modelId: "composer-2.5", parameters: [] }); +}); + +// ─── decode bounds hardening (malformed/hostile wire data) ────────────────── + +test("decodeAgentServerMessage throws on a length-delimited field that overruns the buffer", () => { + function v(n: number): Buffer { + const out: number[] = []; + while (n > 0x7f) { + out.push((n & 0x7f) | 0x80); + n >>>= 7; + } + out.push(n); + return Buffer.from(out); + } + function tag(field: number, wt: number) { + return v((field << 3) | wt); + } + // field 1 (LEN) declaring length 200, but only 3 payload bytes follow. + // Before hardening Buffer.subarray silently clamped to EOF, decoding a + // truncated message as if it were complete; now checkedLen rejects it so + // processFrame skips the corrupt frame instead of acting on partial data. + const malformed = Buffer.concat([tag(1, 2), v(200), Buffer.from([1, 2, 3])]); + assert.throws(() => decodeAgentServerMessage(malformed), /overruns buffer/); +}); + +test("decode bounds hardening does not affect well-formed frames", () => { + // A correctly-sized frame still decodes (regression guard for the new check). + function v(n: number): Buffer { + const out: number[] = []; + while (n > 0x7f) { + out.push((n & 0x7f) | 0x80); + n >>>= 7; + } + out.push(n); + return Buffer.from(out); + } + function tag(field: number, wt: number) { + return v((field << 3) | wt); + } + function lp(field: number, payload: Buffer) { + return Buffer.concat([tag(field, 2), v(payload.length), payload]); + } + const tdu = lp(1, Buffer.from("ok", "utf8")); + const iu = lp(1, tdu); + const asm = lp(1, iu); + assert.deepEqual(decodeAgentServerMessage(asm), [{ kind: "text", text: "ok" }]); +}); + test("encodeAgentRunRequest embeds user text and resolves the model id", () => { const buf = encodeAgentRunRequest({ modelId: "auto", diff --git a/tests/unit/cursor-agent-session.test.ts b/tests/unit/cursor-agent-session.test.ts index 3f0823aabb..f63be412c4 100644 --- a/tests/unit/cursor-agent-session.test.ts +++ b/tests/unit/cursor-agent-session.test.ts @@ -237,6 +237,22 @@ test("CursorSessionManager.sendToolResult returns false when openAIToolCallId no assert.equal(ok, false); }); +test("CursorSessionManager.close clears unanswered pendingToolCalls", () => { + const m = new CursorSessionManager(); + const { req } = mockReq(); + const { client } = mockClient(); + const session = m.open("conv-clear", client, req, new Map()); + session.pendingToolCalls.set("call_unanswered", { + execMsgId: 1, + execId: "exec-1", + toolName: "get_weather", + }); + m.close(session); + // close() drops the unanswered mapping so it isn't pinned on the dead session. + assert.equal(session.pendingToolCalls.size, 0); + assert.equal(m.size(), 0); +}); + test("CursorSessionManager.open replaces an existing session for the same conversation", () => { const m = new CursorSessionManager(); const r1 = mockReq(); diff --git a/tests/unit/cursor-agent-tool-calls.test.ts b/tests/unit/cursor-agent-tool-calls.test.ts index 3d352d506d..a91d3df3c3 100644 --- a/tests/unit/cursor-agent-tool-calls.test.ts +++ b/tests/unit/cursor-agent-tool-calls.test.ts @@ -5,7 +5,7 @@ import { decodeProtobufValue, jsonSchemaToProtobufValue, } from "../../open-sse/utils/cursorAgentProtobuf"; -import { newStreamCtx, processFrame } from "../../open-sse/executors/cursor"; +import { newStreamCtx, processFrame, CursorExecutor } from "../../open-sse/executors/cursor"; // ─── Wire-format helpers ─────────────────────────────────────────────────── @@ -234,3 +234,125 @@ test("processFrame doesn't emit tool_calls for the same exec_id twice", () => { processFrame(payload, ctx, acked); assert.equal(ctx.toolCalls.length, 1); }); + +// ─── Tool-commit directive (ported from composer-api) ────────────────────── +// +// transformRequest() returns the encoded Connect-RPC body; the UserMessage.text +// is plain UTF-8 inside it, so we can assert the directive is present/absent. + +const weatherTool = { + type: "function", + function: { + name: "web_search", + description: "Search the web", + parameters: { type: "object", properties: { q: { type: "string" } } }, + }, +}; + +test("transformRequest prepends the tool-commit directive when tools are declared", () => { + const exec = new CursorExecutor(); + const body = exec.transformRequest( + "composer-2.5", + { messages: [{ role: "user", content: "how's the weather?" }], tools: [weatherTool] }, + false, + {} + ) as Uint8Array; + const text = Buffer.from(body).toString("utf8"); + assert.ok(text.includes("you MUST issue the actual tool call"), "directive present with tools"); + assert.ok(text.includes("how's the weather?"), "original user text preserved"); +}); + +test("transformRequest omits the directive when no tools are declared", () => { + const exec = new CursorExecutor(); + const body = exec.transformRequest( + "composer-2.5", + { messages: [{ role: "user", content: "hi there" }] }, + false, + {} + ) as Uint8Array; + const text = Buffer.from(body).toString("utf8"); + assert.ok(!text.includes("you MUST issue the actual tool call"), "no directive without tools"); + assert.ok(text.includes("hi there"), "user text present"); +}); + +test("transformRequest honors CURSOR_TOOL_DIRECTIVE=0 opt-out", () => { + const prev = process.env.CURSOR_TOOL_DIRECTIVE; + process.env.CURSOR_TOOL_DIRECTIVE = "0"; + try { + const exec = new CursorExecutor(); + const body = exec.transformRequest( + "composer-2.5", + { messages: [{ role: "user", content: "weather?" }], tools: [weatherTool] }, + false, + {} + ) as Uint8Array; + const text = Buffer.from(body).toString("utf8"); + assert.ok(!text.includes("you MUST issue the actual tool call"), "directive suppressed by opt-out"); + } finally { + if (prev === undefined) delete process.env.CURSOR_TOOL_DIRECTIVE; + else process.env.CURSOR_TOOL_DIRECTIVE = prev; + } +}); + +// ─── tool_choice handling (ported from composer-api) ─────────────────────── + +function encodedText(body: Record): string { + const exec = new CursorExecutor(); + const buf = exec.transformRequest("composer-2.5", body, false, {}) as Uint8Array; + return Buffer.from(buf).toString("utf8"); +} + +test("tool_choice:'none' drops tools and the directive", () => { + const text = encodedText({ + messages: [{ role: "user", content: "weather?" }], + tools: [weatherTool], + tool_choice: "none", + }); + assert.ok(!text.includes("you MUST issue the actual tool call"), "no directive when tool_choice none"); + assert.ok(!text.includes("web_search"), "tool not advertised when tool_choice none"); +}); + +test("tool_choice:'required' adds the forcing line", () => { + const text = encodedText({ + messages: [{ role: "user", content: "weather?" }], + tools: [weatherTool], + tool_choice: "required", + }); + assert.ok(text.includes("you MUST issue the actual tool call"), "base directive present"); + assert.ok(text.includes("at least one of the available tools"), "required forcing line present"); +}); + +test("tool_choice specific-function forces that tool by name", () => { + const text = encodedText({ + messages: [{ role: "user", content: "weather?" }], + tools: [weatherTool], + tool_choice: { type: "function", function: { name: "web_search" } }, + }); + assert.ok(text.includes("You MUST call the `web_search` tool now"), "specific tool forced"); +}); + +// ─── output constraints (ported from composer-api) ───────────────────────── + +test("response_format json_object adds a JSON output constraint", () => { + const text = encodedText({ + messages: [{ role: "user", content: "give me a profile" }], + response_format: { type: "json_object" }, + }); + assert.ok(text.includes("OUTPUT CONSTRAINTS:"), "constraints block present"); + assert.ok(text.includes("single valid JSON object"), "json_object constraint present"); +}); + +test("max_tokens and stop are surfaced as output constraints", () => { + const text = encodedText({ + messages: [{ role: "user", content: "hi" }], + max_tokens: 128, + stop: ["END"], + }); + assert.ok(text.includes("within about 128 output tokens"), "max_tokens constraint present"); + assert.ok(text.includes("Stop before any of these sequences: END"), "stop constraint present"); +}); + +test("no output constraints block when no constraining params are set", () => { + const text = encodedText({ messages: [{ role: "user", content: "hi" }] }); + assert.ok(!text.includes("OUTPUT CONSTRAINTS:"), "no constraints block by default"); +}); diff --git a/tests/unit/cursor-image-input.test.ts b/tests/unit/cursor-image-input.test.ts new file mode 100644 index 0000000000..1e16035c8f --- /dev/null +++ b/tests/unit/cursor-image-input.test.ts @@ -0,0 +1,437 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + encodeSelectedImageBody, + encodeAgentRunRequest, + type EncodedImage, +} from "../../open-sse/utils/cursorAgentProtobuf"; +import dns from "node:dns"; +import { + resolveCursorImages, + extractImageUrls, + assertResolvedAddressesPublic, + CursorImageError, + MAX_CURSOR_IMAGE_BYTES, + MAX_CURSOR_IMAGES, +} from "../../open-sse/utils/cursorImages"; +import { CursorExecutor } from "../../open-sse/executors/cursor"; + +// A public IP for mocking DNS so the redirect tests (which use non-resolvable +// example hostnames) pass the DNS-rebinding gate. +const PUBLIC_IP = [{ address: "93.184.216.34", family: 4 }]; + +// ─── Minimal protobuf field walker (test-only) ────────────────────────────── +// Mirrors the production decoder enough to assert field layout without exposing +// the internal decodeFields helper. +type WalkField = + | { fn: number; wt: 0; varint: bigint } + | { fn: number; wt: 2; bytes: Buffer }; + +function walk(buf: Buffer): WalkField[] { + const out: WalkField[] = []; + let pos = 0; + const varint = (): bigint => { + let r = 0n; + let s = 0n; + for (;;) { + const b = buf[pos++]; + r |= BigInt(b & 0x7f) << s; + if (!(b & 0x80)) break; + s += 7n; + } + return r; + }; + while (pos < buf.length) { + const tag = varint(); + const fn = Number(tag >> 3n); + const wt = Number(tag & 7n); + if (wt === 0) { + out.push({ fn, wt: 0, varint: varint() }); + } else if (wt === 2) { + const len = Number(varint()); + out.push({ fn, wt: 2, bytes: buf.subarray(pos, pos + len) }); + pos += len; + } else if (wt === 5) { + pos += 4; + } else if (wt === 1) { + pos += 8; + } else { + throw new Error(`bad wireType ${wt}`); + } + } + return out; +} +const find = (fields: WalkField[], fn: number) => fields.find((f) => f.fn === fn); +const lenBytes = (fields: WalkField[], fn: number): Buffer => { + const f = find(fields, fn); + assert.ok(f && f.wt === 2, `expected len field ${fn}`); + return Buffer.from((f as { bytes: Buffer }).bytes); +}; + +// Navigate AgentClientMessage(1) -> AgentRunRequest -> action(2) -> +// ConversationAction -> user_message_action(1) -> UserMessageAction -> +// user_message(1) -> UserMessage. +function navUserMessage(req: Buffer): WalkField[] { + const acm = walk(req); + const arr = walk(lenBytes(acm, 1)); + const action = walk(lenBytes(arr, 2)); + const uma = walk(lenBytes(action, 1)); + return walk(lenBytes(uma, 1)); +} + +// ─── encodeSelectedImageBody field layout ─────────────────────────────────── + +test("encodeSelectedImageBody emits uuid(2), dimension(4), mime_type(7), data(8)", () => { + const data = Buffer.from([1, 2, 3, 4, 5]); + const body = encodeSelectedImageBody({ + data, + mimeType: "image/png", + width: 10, + height: 20, + uuid: "abc-123", + }); + const fields = walk(body); + + assert.equal(lenBytes(fields, 2).toString("utf8"), "abc-123"); // uuid + const dim = walk(lenBytes(fields, 4)); // dimension submessage + assert.equal(Number((find(dim, 1) as { varint: bigint }).varint), 10); // width + assert.equal(Number((find(dim, 2) as { varint: bigint }).varint), 20); // height + assert.equal(lenBytes(fields, 7).toString("utf8"), "image/png"); // mime_type + assert.deepEqual(lenBytes(fields, 8), data); // inline data (oneof case) +}); + +test("encodeSelectedImageBody omits dimension/mime_type when not provided", () => { + const body = encodeSelectedImageBody({ data: Buffer.from([9]), uuid: "u" }); + const fields = walk(body); + assert.equal(find(fields, 4), undefined, "no dimension"); + assert.equal(find(fields, 7), undefined, "no mime_type"); + assert.ok(find(fields, 2), "uuid present"); + assert.deepEqual(lenBytes(fields, 8), Buffer.from([9]), "data present"); +}); + +test("encodeSelectedImageBody omits dimension when width/height are invalid", () => { + const body = encodeSelectedImageBody({ + data: Buffer.from([1]), + uuid: "u", + width: 0, + height: -5, + }); + assert.equal(find(walk(body), 4), undefined, "zero/negative dims dropped"); +}); + +// ─── No-image path is byte-identical to today ─────────────────────────────── + +test("no-image request is byte-identical to images:undefined and images:[]", () => { + const base = { + modelId: "auto", + userText: "hello world", + conversationId: "fixed-conv", + messageId: "fixed-msg", + }; + const plain = encodeAgentRunRequest({ ...base }); + const undef = encodeAgentRunRequest({ ...base, images: undefined }); + const empty = encodeAgentRunRequest({ ...base, images: [] }); + assert.ok(plain.equals(undef), "images:undefined matches no images"); + assert.ok(plain.equals(empty), "images:[] matches no images"); + + // And selected_context (field 3) is present but empty in the no-image case. + const um = navUserMessage(plain); + const sc = find(um, 3); + assert.ok(sc && sc.wt === 2, "selected_context present"); + assert.equal((sc as { bytes: Buffer }).bytes.length, 0, "selected_context empty"); +}); + +// ─── Images attach under UserMessage.selected_context.selected_images ──────── + +test("images attach as selected_context.selected_images[] with inline data", () => { + const imgs: EncodedImage[] = [ + { data: Buffer.from([0xaa, 0xbb]), mimeType: "image/png", uuid: "u1" }, + { data: Buffer.from([0xcc]), mimeType: "image/jpeg", uuid: "u2" }, + ]; + const req = encodeAgentRunRequest({ + modelId: "gpt-5.2", + userText: "what colors?", + conversationId: "c", + messageId: "m", + images: imgs, + }); + const um = navUserMessage(req); + const sc = walk(lenBytes(um, 3)); // SelectedContext + const selectedImages = sc.filter((f) => f.fn === 1 && f.wt === 2); + assert.equal(selectedImages.length, 2, "two selected_images entries"); + + const first = walk(Buffer.from((selectedImages[0] as { bytes: Buffer }).bytes)); + assert.equal(lenBytes(first, 2).toString("utf8"), "u1"); + assert.equal(lenBytes(first, 7).toString("utf8"), "image/png"); + assert.deepEqual(lenBytes(first, 8), Buffer.from([0xaa, 0xbb])); + + const second = walk(Buffer.from((selectedImages[1] as { bytes: Buffer }).bytes)); + assert.deepEqual(lenBytes(second, 8), Buffer.from([0xcc])); + + // UserMessage.text (field 1) still carries the prompt text alongside images. + assert.equal(lenBytes(um, 1).toString("utf8"), "what colors?"); +}); + +// ─── extractImageUrls ─────────────────────────────────────────────────────── + +test("extractImageUrls pulls urls from object and string image_url parts", () => { + assert.deepEqual( + extractImageUrls([ + { type: "text", text: "hi" }, + { type: "image_url", image_url: { url: "data:image/png;base64,AA" } }, + { type: "image_url", image_url: "https://x.test/y.png" }, + { type: "image_url", image_url: { detail: "high" } }, // no url -> ignored + ]), + ["data:image/png;base64,AA", "https://x.test/y.png"] + ); + assert.deepEqual(extractImageUrls("plain string content"), []); + assert.deepEqual(extractImageUrls(null), []); +}); + +// ─── resolveCursorImages: happy path ──────────────────────────────────────── + +test("resolveCursorImages decodes a valid base64 data URI", async () => { + const png = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + const out = await resolveCursorImages([`data:image/png;base64,${png.toString("base64")}`]); + assert.equal(out.length, 1); + assert.deepEqual(out[0].data, png); + assert.equal(out[0].mimeType, "image/png"); + assert.ok(out[0].uuid && out[0].uuid.length > 0); +}); + +// ─── resolveCursorImages: rejections (all CursorImageError, all sanitized) ─── + +test("resolveCursorImages rejects a non-image data URI", async () => { + await assert.rejects( + () => resolveCursorImages(["data:text/plain;base64,aGVsbG8="]), + (e) => e instanceof CursorImageError + ); +}); + +test("resolveCursorImages rejects invalid base64", async () => { + await assert.rejects( + () => resolveCursorImages(["data:image/png;base64,@@@@"]), + (e) => e instanceof CursorImageError + ); +}); + +test("resolveCursorImages rejects a non-base64 data URI", async () => { + await assert.rejects( + () => resolveCursorImages(["data:image/png,not-base64-payload"]), + (e) => e instanceof CursorImageError + ); +}); + +test("resolveCursorImages rejects an oversized image (>1 MiB)", async () => { + const big = Buffer.alloc(MAX_CURSOR_IMAGE_BYTES + 16).toString("base64"); + await assert.rejects( + () => resolveCursorImages([`data:image/png;base64,${big}`]), + (e) => e instanceof CursorImageError + ); +}); + +test("resolveCursorImages blocks SSRF targets (localhost, link-local, file://)", async () => { + for (const url of [ + "http://127.0.0.1/x.png", + "http://localhost:8080/x.png", + "http://169.254.169.254/latest/meta-data/", + "http://[::1]/x.png", + "http://10.0.0.5/x.png", + "file:///etc/passwd", + ]) { + await assert.rejects( + () => resolveCursorImages([url]), + (e) => e instanceof CursorImageError, + `expected ${url} to be blocked` + ); + } +}); + +test("resolveCursorImages rejects too many images", async () => { + const one = "data:image/png;base64,AAAA"; + await assert.rejects( + () => resolveCursorImages(Array.from({ length: MAX_CURSOR_IMAGES + 1 }, () => one)), + (e) => e instanceof CursorImageError + ); +}); + +test("resolveCursorImages accepts an uppercase DATA: scheme (RFC 2397 case-insensitive)", async () => { + const png = Buffer.from([137, 80, 78, 71]); + const out = await resolveCursorImages([`DATA:image/png;base64,${png.toString("base64")}`]); + assert.equal(out.length, 1); + assert.deepEqual(out[0].data, png); + assert.equal(out[0].mimeType, "image/png"); +}); + +test("assertResolvedAddressesPublic blocks private/metadata IPs, allows public", () => { + for (const ip of ["127.0.0.1", "10.0.0.1", "169.254.169.254", "192.168.1.1", "::1", "fd00::1"]) { + assert.throws(() => assertResolvedAddressesPublic([ip]), CursorImageError, `should block ${ip}`); + } + assert.doesNotThrow(() => assertResolvedAddressesPublic(["93.184.216.34", "1.1.1.1"])); + // A single private answer among public ones still blocks (DNS-rebinding). + assert.throws(() => assertResolvedAddressesPublic(["8.8.8.8", "127.0.0.1"]), CursorImageError); +}); + +test("resolveCursorImages blocks DNS rebinding (public host resolving to a private IP)", async (t) => { + t.mock.method(dns.promises, "lookup", async () => [{ address: "127.0.0.1", family: 4 }]); + const realFetch = globalThis.fetch; + // fetch should never be reached — the DNS gate blocks first. + globalThis.fetch = async () => { + throw new Error("fetch must not run for a rebinding host"); + }; + try { + await assert.rejects( + () => resolveCursorImages(["https://rebind.attacker.example/a.png"]), + (e) => e instanceof CursorImageError && /blocked address/i.test((e as Error).message) + ); + } finally { + globalThis.fetch = realFetch; + } +}); + +test("resolveCursorImages re-validates redirects: a 30x to a private host is blocked (SSRF)", async (t) => { + // fetch() follows redirects by default; the resolver uses redirect:"manual" + // and re-validates each hop. A public URL that 302s to 127.0.0.1 must be + // blocked, not followed. + t.mock.method(dns.promises, "lookup", async () => PUBLIC_IP); + const realFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response(null, { status: 302, headers: { location: "http://127.0.0.1/secret.png" } }); + try { + await assert.rejects( + () => resolveCursorImages(["https://public.example/a.png"]), + (e) => e instanceof CursorImageError && /blocked address/i.test((e as Error).message) + ); + } finally { + globalThis.fetch = realFetch; + } +}); + +test("resolveCursorImages follows a redirect to another public host and reads the image", async (t) => { + t.mock.method(dns.promises, "lookup", async () => PUBLIC_IP); + const png = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + const realFetch = globalThis.fetch; + let call = 0; + globalThis.fetch = async () => { + call++; + if (call === 1) { + return new Response(null, { + status: 302, + headers: { location: "https://cdn.public.example/a.png" }, + }); + } + return new Response(new Uint8Array(png), { + status: 200, + headers: { "content-type": "image/png" }, + }); + }; + try { + const out = await resolveCursorImages(["https://public.example/a.png"]); + assert.equal(out.length, 1); + assert.deepEqual(out[0].data, png); + assert.equal(out[0].mimeType, "image/png"); + } finally { + globalThis.fetch = realFetch; + } +}); + +test("resolveCursorImages rejects an over-long redirect chain", async (t) => { + t.mock.method(dns.promises, "lookup", async () => PUBLIC_IP); + const realFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response(null, { + status: 302, + headers: { location: "https://public.example/loop.png" }, + }); + try { + await assert.rejects( + () => resolveCursorImages(["https://public.example/start.png"]), + (e) => e instanceof CursorImageError && /too many redirects/i.test((e as Error).message) + ); + } finally { + globalThis.fetch = realFetch; + } +}); + +// ─── Executor-level error body (response path, hard rule #12) ─────────────── + +test("executor returns a sanitized 400 for an oversized image", async () => { + // buildRequest throws CursorImageError before any network/session/DB work, + // so this stays fully offline (no token needed). + const exec = new CursorExecutor(); + const big = Buffer.alloc(MAX_CURSOR_IMAGE_BYTES + 16).toString("base64"); + const result = await exec.execute({ + model: "gpt-5.2", + body: { + messages: [ + { + role: "user", + content: [ + { type: "text", text: "what color?" }, + { type: "image_url", image_url: { url: `data:image/png;base64,${big}` } }, + ], + }, + ], + }, + stream: false, + credentials: { accessToken: "test-token" }, + signal: undefined, + log: () => {}, + upstreamExtraHeaders: undefined, + }); + assert.equal(result.response.status, 400); + const body = await result.response.json(); + assert.ok(body.error, "error envelope present"); + assert.match(body.error.message, /too large/i); + // No stack-trace / source-path leakage in the response body (hard rule #12). + assert.ok(!body.error.message.includes("at /"), "no stack frame in error body"); + assert.ok(!/\/(root|home|usr)\//.test(body.error.message), "no absolute path in error body"); +}); + +test("executor returns a sanitized 400 for an SSRF-blocked image URL", async () => { + const exec = new CursorExecutor(); + const result = await exec.execute({ + model: "gpt-5.2", + body: { + messages: [ + { + role: "user", + content: [ + { type: "text", text: "what color?" }, + { type: "image_url", image_url: { url: "http://169.254.169.254/latest/" } }, + ], + }, + ], + }, + stream: false, + credentials: { accessToken: "test-token" }, + signal: undefined, + log: () => {}, + upstreamExtraHeaders: undefined, + }); + assert.equal(result.response.status, 400); + const body = await result.response.json(); + assert.ok(!body.error.message.includes("at /"), "no stack frame in error body"); +}); + +test("CursorImageError messages never leak stack traces or paths", async () => { + // Every rejection message must be a clean human string (no "at /" frames, + // no absolute paths) so the executor's sanitized 400 body stays clean + // (hard rule #12). + const triggers = [ + "data:text/plain;base64,aGVsbG8=", + "data:image/png;base64,@@@@", + "http://127.0.0.1/x.png", + "file:///etc/passwd", + ]; + for (const url of triggers) { + await resolveCursorImages([url]).then( + () => assert.fail(`expected rejection for ${url}`), + (e) => { + assert.ok(e instanceof CursorImageError); + assert.ok(!/\bat \//.test(e.message), `no stack frame in: ${e.message}`); + assert.ok(!/\/(root|home|usr)\//.test(e.message), `no abs path in: ${e.message}`); + } + ); + } +}); diff --git a/tests/unit/cursor-streaming.test.ts b/tests/unit/cursor-streaming.test.ts index 343e92cdcd..068c723001 100644 --- a/tests/unit/cursor-streaming.test.ts +++ b/tests/unit/cursor-streaming.test.ts @@ -125,6 +125,20 @@ test("processFrame sets endReason on kv_server_message after text", () => { assert.equal(ctx.kvAfterTextSeen, true); }); +test("buildCursorUsage degrades to prompt-only counts for an empty response", () => { + // emitUsage now always emits on the success path (OpenAI streaming contract), + // relying on buildCursorUsage producing a valid usage object even when the + // model returned no text/thinking/token_delta. + const ctx = newStreamCtx("auto", () => {}); + const usage = buildCursorUsage(ctx, { + messages: [{ role: "user", content: "hi" }], + }); + assert.equal(typeof usage.prompt_tokens, "number"); + assert.equal(usage.completion_tokens, 0); + assert.equal(usage.total_tokens, usage.prompt_tokens); + assert.equal(usage.estimated, true); +}); + test("processFrame ignores kv_server_message before text (no end signal yet)", () => { const ctx = newStreamCtx("auto", () => {}); processFrame(buildKvServerMessagePayload(), ctx, new Set()); diff --git a/tests/unit/cursor-version-detector.test.mjs b/tests/unit/cursor-version-detector.test.mjs index 78fe5e877a..e4739c9919 100644 --- a/tests/unit/cursor-version-detector.test.mjs +++ b/tests/unit/cursor-version-detector.test.mjs @@ -4,10 +4,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -const FALLBACK_VERSION = "3.2.14"; const Database = (await import("better-sqlite3")).default; -const { getCursorVersion, resetCursorVersionCache } = +const { getCursorVersion, resetCursorVersionCache, FALLBACK_VERSION } = await import("../../open-sse/utils/cursorVersionDetector.ts"); function createStateDb(dir, version) { diff --git a/tests/unit/translator-openai-to-cursor.test.ts b/tests/unit/translator-openai-to-cursor.test.ts index 3967a2787c..2d818f8a09 100644 --- a/tests/unit/translator-openai-to-cursor.test.ts +++ b/tests/unit/translator-openai-to-cursor.test.ts @@ -144,3 +144,58 @@ test("OpenAI -> Cursor converts tool role messages using remembered tool metadat assert.match(result.messages[1].content, /search_docs<\/tool_name>/); assert.match(result.messages[1].content, /found it<\/result>/); }); + +test("OpenAI -> Cursor preserves image_url parts so vision input survives", () => { + const dataUri = "data:image/png;base64,iVBORw0KGgo="; + const result = buildCursorRequest( + "gpt-5.2", + { + messages: [ + { + role: "user", + content: [ + { type: "text", text: "what color?" }, + { type: "image_url", image_url: { url: dataUri } }, + { type: "image_url", image_url: { url: "https://example.com/a.png" } }, + ], + }, + ], + }, + false, + null + ); + + // Content is kept as an OpenAI array: a leading text part + the image parts. + assert.equal(result.messages.length, 1); + assert.equal(result.messages[0].role, "user"); + const content = result.messages[0].content; + assert.ok(Array.isArray(content), "content preserved as array"); + assert.deepEqual(content[0], { type: "text", text: "what color?" }); + assert.deepEqual(content[1], { type: "image_url", image_url: { url: dataUri } }); + assert.deepEqual(content[2], { + type: "image_url", + image_url: { url: "https://example.com/a.png" }, + }); +}); + +test("OpenAI -> Cursor accepts shorthand image_url string form", () => { + const result = buildCursorRequest( + "gpt-5.2", + { + messages: [ + { + role: "user", + content: [{ type: "image_url", image_url: "data:image/png;base64,AAAA" }], + }, + ], + }, + false, + null + ); + const content = result.messages[0].content; + assert.ok(Array.isArray(content)); + // No text part (none supplied) — just the normalized image part. + assert.deepEqual(content, [ + { type: "image_url", image_url: { url: "data:image/png;base64,AAAA" } }, + ]); +});