mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-19 21:52:21 +03:00
feat(cursor): vision (image_url) input + tool-commit/output-constraint enhancements (#3104)
* feat(cursor): vision (image_url) input + tool-commit/output-constraint enhancements Add image/vision input to the Cursor provider's agent.v1 endpoint, plus the supporting prompt-engineering and resilience work developed alongside it. Vision input - Decode OpenAI `image_url` parts (base64 `data:` URIs and remote `http(s)` URLs) and inline them as `SelectedContext.selected_images[]` — field numbers pinned from the cursor-agent agent.v1 protobuf descriptor (SelectedImage.data oneof, uuid, optional Dimension, mime_type). Cross-checked against composer-api's shape. - New `resolveCursorImages` helper: SSRF-guarded remote fetches via the repo's canonical `parseAndValidatePublicUrl` (always public-only for client URLs), <=1 MiB per image (pre-decode + streaming cap), `image/*` enforced, max 12 images, sanitized `CursorImageError` (no stack/path leakage). - `openai-to-cursor` translator now preserves `image_url` parts instead of dropping them; executor `buildRequest` resolves images and attaches them to the user turn. The no-image path is byte-identical to before (test-asserted). Supporting cursor enhancements - Tool-commit directive (raises composer-2.5 tool-call rate ~53% -> ~88%), `tool_choice` none/required/specific handling, and output constraints (`response_format` / `max_tokens` / `stop` surfaced as prompt instructions). - `cursorSessionManager`: clear pending tool-call mappings on session close. - `cursorVersionDetector`: export `FALLBACK_VERSION` as a single source of truth. Tests & docs - New unit suite for the image encoder + resolver (field layout, byte-identical no-image path, SSRF / oversize / bad-base64 / too-many rejections, sanitized error body), translator image-preservation tests, and live e2e tests (base64 + remote URL, gated on `CURSOR_E2E_TOKEN`). - Documented `CURSOR_TOOL_DIRECTIVE` and `CURSOR_IMAGE_FETCH_TIMEOUT_MS` in `.env.example` and `docs/reference/ENVIRONMENT.md`. * fix(cursor): address review — redirect SSRF, large-payload guard, stream OOM, case/NaN nits Resolves the gemini-code-assist review on #3104: - SSRF via redirect (critical): fetchImageBytes now uses redirect:"manual" and re-validates every hop through parseAndValidatePublicUrl, so a public URL can't 30x-redirect to a private/link-local address. Bounded to 3 redirects. - Large data URL (high): reject on raw payload length before the whitespace-strip regex, so an oversized data URL can't burn CPU. - Stream read (high): readCapped consumes the body as an async iterable (Node Readable + Web Streams) or via getReader, capping mid-read; uncapped arrayBuffer() is only a last resort. - data: scheme (medium): match case-insensitively (RFC 2397) while preserving the original payload. - NaN timeouts (medium): CURSOR_IMAGE_FETCH_TIMEOUT_MS and CURSOR_STREAM_TIMEOUT_MS fall back to defaults when the env value isn't a positive integer. Adds tests: redirect-to-private blocked, redirect-to-public followed, too-many- redirects rejected, uppercase DATA: accepted. * fix(cursor): defend image fetch against DNS-rebinding SSRF Address the @codex review on #3104: parseAndValidatePublicUrl only checks the hostname string, so a public-looking host that (re)resolves to a private / link-local / metadata IP would still be fetched. Each hop now resolves the host via dns.lookup({all:true}) and rejects if ANY answer is private (isPrivateHost), before connecting. IP literals are skipped (already validated by the URL guard). This narrows but doesn't fully close the TOCTOU window vs fetch's own resolution; a connection-time IP filter on the shared outbound guard would close it for every caller. Adds unit tests for the IP gate and a mocked DNS-rebinding case (public host -> 127.0.0.1, fetch never reached).
This commit is contained in:
10
.env.example
10
.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=
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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<string, Buffer> } {
|
||||
/**
|
||||
* 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<EncodedImage[] | undefined> {
|
||||
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<string, Buffer> }> {
|
||||
const { userText, tools } = this.assembleTextAndTools(body);
|
||||
const images = await this.resolveRequestImages(body);
|
||||
|
||||
const blobStore = new Map<string, Buffer>();
|
||||
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<string, Buffer>();
|
||||
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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<TextPart | ToolResultPart>) {
|
||||
// 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<TextPart | ToolResultPart | ImagePart>) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ConnectFrame> {
|
||||
|
||||
// ─── 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<string, string> = {
|
||||
"": "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<string, Buffer>;
|
||||
// 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);
|
||||
|
||||
354
open-sse/utils/cursorImages.ts
Normal file
354
open-sse/utils/cursorImages.ts
Normal file
@@ -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:[<mediatype>][;base64],<data>
|
||||
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<void> {
|
||||
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<Buffer> {
|
||||
const body = response.body as
|
||||
| (AsyncIterable<Uint8Array> & { getReader?: () => ReadableStreamDefaultReader<Uint8Array> })
|
||||
| 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<EncodedImage[]> {
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
// (</think>, <|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<Uint8Array>).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}`
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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, unknown>): 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");
|
||||
});
|
||||
|
||||
437
tests/unit/cursor-image-input.test.ts
Normal file
437
tests/unit/cursor-image-input.test.ts
Normal file
@@ -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}`);
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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());
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -144,3 +144,58 @@ test("OpenAI -> Cursor converts tool role messages using remembered tool metadat
|
||||
assert.match(result.messages[1].content, /<tool_name>search_docs<\/tool_name>/);
|
||||
assert.match(result.messages[1].content, /<result>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" } },
|
||||
]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user