mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
* 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).
359 lines
14 KiB
TypeScript
359 lines
14 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import {
|
|
decodeExecServerEvent,
|
|
decodeProtobufValue,
|
|
jsonSchemaToProtobufValue,
|
|
} from "../../open-sse/utils/cursorAgentProtobuf";
|
|
import { newStreamCtx, processFrame, CursorExecutor } from "../../open-sse/executors/cursor";
|
|
|
|
// ─── Wire-format helpers ───────────────────────────────────────────────────
|
|
|
|
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, wireType: number): Buffer {
|
|
return v((field << 3) | wireType);
|
|
}
|
|
function lenPrefixed(field: number, payload: Buffer): Buffer {
|
|
return Buffer.concat([tag(field, 2), v(payload.length), payload]);
|
|
}
|
|
function stringField(field: number, value: string): Buffer {
|
|
return lenPrefixed(field, Buffer.from(value, "utf8"));
|
|
}
|
|
function varintField(field: number, value: number): Buffer {
|
|
return Buffer.concat([tag(field, 0), v(value)]);
|
|
}
|
|
|
|
// Encodes a single map<string, bytes> entry: { key (1): k, value (2): valueBytes }
|
|
function mapEntry(field: number, key: string, valueBytes: Buffer): Buffer {
|
|
const entry = Buffer.concat([stringField(1, key), lenPrefixed(2, valueBytes)]);
|
|
return lenPrefixed(field, entry);
|
|
}
|
|
|
|
// AgentServerMessage { exec_server_message (2): ESM { id, exec_id, mcp_args (11): ... } }
|
|
function buildMcpArgsEvent(
|
|
execMsgId: number,
|
|
execId: string,
|
|
toolName: string,
|
|
toolCallId: string,
|
|
args: Record<string, unknown>
|
|
): Buffer {
|
|
const mcaParts: Buffer[] = [
|
|
stringField(1, toolName), // name
|
|
stringField(3, toolCallId), // tool_call_id
|
|
];
|
|
for (const [k, val] of Object.entries(args)) {
|
|
const valueBytes = jsonSchemaToProtobufValue(val);
|
|
mcaParts.push(mapEntry(2, k, valueBytes)); // args = field 2
|
|
}
|
|
const mca = Buffer.concat(mcaParts);
|
|
const esm = Buffer.concat([
|
|
varintField(1, execMsgId),
|
|
stringField(15, execId),
|
|
lenPrefixed(11, mca),
|
|
]);
|
|
return lenPrefixed(2, esm);
|
|
}
|
|
|
|
// ─── decodeProtobufValue round-trip tests ──────────────────────────────────
|
|
|
|
test("decodeProtobufValue round-trips primitives", () => {
|
|
assert.equal(decodeProtobufValue(jsonSchemaToProtobufValue("hi") as Buffer), "hi");
|
|
assert.equal(decodeProtobufValue(jsonSchemaToProtobufValue(42.5)), 42.5);
|
|
assert.equal(decodeProtobufValue(jsonSchemaToProtobufValue(true)), true);
|
|
assert.equal(decodeProtobufValue(jsonSchemaToProtobufValue(false)), false);
|
|
assert.equal(decodeProtobufValue(jsonSchemaToProtobufValue(null)), null);
|
|
});
|
|
|
|
test("decodeProtobufValue round-trips a flat object", () => {
|
|
const obj = { name: "alice", age: 30, active: true };
|
|
const encoded = jsonSchemaToProtobufValue(obj);
|
|
assert.deepEqual(decodeProtobufValue(encoded), obj);
|
|
});
|
|
|
|
test("decodeProtobufValue round-trips an array of strings", () => {
|
|
const arr = ["a", "b", "c"];
|
|
const encoded = jsonSchemaToProtobufValue(arr);
|
|
assert.deepEqual(decodeProtobufValue(encoded), arr);
|
|
});
|
|
|
|
test("decodeProtobufValue round-trips a nested object with mixed types", () => {
|
|
const obj = {
|
|
city: "Paris",
|
|
coords: { lat: 48.8566, lng: 2.3522 },
|
|
tags: ["europe", "capital"],
|
|
populated: true,
|
|
sister_city: null,
|
|
};
|
|
const encoded = jsonSchemaToProtobufValue(obj);
|
|
assert.deepEqual(decodeProtobufValue(encoded), obj);
|
|
});
|
|
|
|
test("decodeProtobufValue handles deeply nested OpenAI tool args", () => {
|
|
const args = {
|
|
location: { city: "Tokyo", country: "JP" },
|
|
units: "metric",
|
|
days: 7,
|
|
};
|
|
const encoded = jsonSchemaToProtobufValue(args);
|
|
assert.deepEqual(decodeProtobufValue(encoded), args);
|
|
});
|
|
|
|
// ─── McpArgs decoding tests ────────────────────────────────────────────────
|
|
|
|
test("decodeExecServerEvent populates args dict from McpArgs map entries", () => {
|
|
const args = { city: "Paris", units: "celsius" };
|
|
const event = decodeExecServerEvent(
|
|
buildMcpArgsEvent(1, "exec-w", "get_weather", "call_abc", args)
|
|
);
|
|
assert.equal(event?.kind, "exec_mcp");
|
|
if (event?.kind !== "exec_mcp") return;
|
|
assert.equal(event.toolName, "get_weather");
|
|
assert.equal(event.toolCallId, "call_abc");
|
|
assert.deepEqual(event.args, args);
|
|
});
|
|
|
|
test("decodeExecServerEvent handles McpArgs with empty args", () => {
|
|
const event = decodeExecServerEvent(buildMcpArgsEvent(1, "exec-x", "no_args_tool", "call_x", {}));
|
|
if (event?.kind !== "exec_mcp") throw new Error("expected exec_mcp");
|
|
assert.deepEqual(event.args, {});
|
|
});
|
|
|
|
test("decodeExecServerEvent handles McpArgs with nested object args", () => {
|
|
const args = {
|
|
location: { city: "London", country: "UK" },
|
|
days: 3,
|
|
};
|
|
const event = decodeExecServerEvent(buildMcpArgsEvent(1, "exec-n", "weather", "call_n", args));
|
|
if (event?.kind !== "exec_mcp") throw new Error("expected exec_mcp");
|
|
assert.deepEqual(event.args, args);
|
|
});
|
|
|
|
// ─── Streaming SSE tool_calls emission ─────────────────────────────────────
|
|
|
|
function parseChunk(line: string): {
|
|
delta?: {
|
|
tool_calls?: Array<{
|
|
index: number;
|
|
id?: string;
|
|
function?: { name?: string; arguments?: string };
|
|
}>;
|
|
};
|
|
finish_reason?: string | null;
|
|
} {
|
|
return JSON.parse(line.replace(/^data: /, "").trim()).choices[0];
|
|
}
|
|
|
|
test("processFrame emits OpenAI tool_calls deltas for an mcp_args event", () => {
|
|
const emitted: string[] = [];
|
|
const ctx = newStreamCtx("auto", (s) => emitted.push(s));
|
|
const payload = buildMcpArgsEvent(1, "exec-tc", "get_weather", "cursor_call_x", {
|
|
city: "Paris",
|
|
});
|
|
processFrame(payload, ctx, new Set());
|
|
|
|
// Expect: role chunk + tool_calls init chunk + tool_calls args chunk
|
|
assert.equal(emitted.length, 3);
|
|
const roleChunk = parseChunk(emitted[0]);
|
|
assert.ok(roleChunk.delta);
|
|
// Init chunk: id + name
|
|
const initChunk = parseChunk(emitted[1]);
|
|
const initCall = initChunk.delta?.tool_calls?.[0];
|
|
assert.equal(initCall?.index, 0);
|
|
assert.match(initCall?.id ?? "", /^call_/);
|
|
assert.equal(initCall?.function?.name, "get_weather");
|
|
assert.equal(initCall?.function?.arguments, "");
|
|
// Args chunk
|
|
const argsChunk = parseChunk(emitted[2]);
|
|
const argsCall = argsChunk.delta?.tool_calls?.[0];
|
|
assert.equal(argsCall?.index, 0);
|
|
assert.equal(argsCall?.function?.arguments, JSON.stringify({ city: "Paris" }));
|
|
|
|
// Side-effects on ctx
|
|
assert.equal(ctx.toolCalls.length, 1);
|
|
assert.equal(ctx.toolCalls[0].name, "get_weather");
|
|
assert.equal(ctx.emittedToolCallIndex, 1);
|
|
});
|
|
|
|
test("processFrame indexes parallel tool calls sequentially", () => {
|
|
const emitted: string[] = [];
|
|
const ctx = newStreamCtx("auto", (s) => emitted.push(s));
|
|
const acked = new Set<string>();
|
|
processFrame(buildMcpArgsEvent(1, "exec-1", "tool_a", "ca", { x: 1 }), ctx, acked);
|
|
processFrame(buildMcpArgsEvent(2, "exec-2", "tool_b", "cb", { y: 2 }), ctx, acked);
|
|
assert.equal(ctx.toolCalls.length, 2);
|
|
assert.equal(ctx.emittedToolCallIndex, 2);
|
|
// emitted[0] = role chunk
|
|
// emitted[1] = init for tool_a (index=0)
|
|
// emitted[2] = args for tool_a (index=0)
|
|
// emitted[3] = init for tool_b (index=1)
|
|
// emitted[4] = args for tool_b (index=1)
|
|
const initB = parseChunk(emitted[3]);
|
|
assert.equal(initB.delta?.tool_calls?.[0].index, 1);
|
|
assert.equal(initB.delta?.tool_calls?.[0].function?.name, "tool_b");
|
|
});
|
|
|
|
test("processFrame emits text + tool_calls in mixed order", () => {
|
|
const emitted: string[] = [];
|
|
const ctx = newStreamCtx("auto", (s) => emitted.push(s));
|
|
const acked = new Set<string>();
|
|
|
|
// Build a text_delta payload
|
|
function textDelta(text: string): Buffer {
|
|
const tdu = lenPrefixed(1, Buffer.from(text, "utf8"));
|
|
const iu = lenPrefixed(1, tdu);
|
|
return lenPrefixed(1, iu);
|
|
}
|
|
|
|
processFrame(textDelta("Let me check"), ctx, acked);
|
|
processFrame(buildMcpArgsEvent(1, "exec-mix", "lookup", "c1", {}), ctx, acked);
|
|
|
|
assert.equal(ctx.totalText, "Let me check");
|
|
assert.equal(ctx.toolCalls.length, 1);
|
|
// Order: role chunk, content chunk, tool_call init, tool_call args
|
|
assert.equal(emitted.length, 4);
|
|
const contentChunk = JSON.parse(emitted[1].replace(/^data: /, "").trim());
|
|
assert.equal(contentChunk.choices[0].delta.content, "Let me check");
|
|
const initChunk = JSON.parse(emitted[2].replace(/^data: /, "").trim());
|
|
assert.ok(initChunk.choices[0].delta.tool_calls);
|
|
});
|
|
|
|
test("processFrame doesn't emit tool_calls for the same exec_id twice", () => {
|
|
const emitted: string[] = [];
|
|
const ctx = newStreamCtx("auto", (s) => emitted.push(s));
|
|
const acked = new Set<string>();
|
|
const payload = buildMcpArgsEvent(1, "exec-dup", "tool", "cd", {});
|
|
processFrame(payload, ctx, acked);
|
|
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");
|
|
});
|