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).
202 lines
5.4 KiB
TypeScript
202 lines
5.4 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
const { buildCursorRequest } =
|
|
await import("../../open-sse/translator/request/openai-to-cursor.ts");
|
|
|
|
test("OpenAI -> Cursor rewrites system prompts and preserves assistant tool calls", () => {
|
|
const result = buildCursorRequest(
|
|
"gpt-4o",
|
|
{
|
|
messages: [
|
|
{ role: "system", content: "Rules" },
|
|
{ role: "user", content: "Hello" },
|
|
{
|
|
role: "assistant",
|
|
content: "Working",
|
|
tool_calls: [
|
|
{
|
|
id: "call_1",
|
|
type: "function",
|
|
function: { name: "read_file", arguments: '{"path":"/tmp/a"}' },
|
|
index: 0,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
false,
|
|
null
|
|
);
|
|
|
|
assert.deepEqual(result.messages[0], {
|
|
role: "user",
|
|
content: "[System Instructions]\nRules",
|
|
});
|
|
assert.deepEqual(result.messages[1], {
|
|
role: "user",
|
|
content: "Hello",
|
|
});
|
|
assert.deepEqual(result.messages[2], {
|
|
role: "assistant",
|
|
content: "Working",
|
|
tool_calls: [
|
|
{
|
|
id: "call_1",
|
|
type: "function",
|
|
function: { name: "read_file", arguments: '{"path":"/tmp/a"}' },
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test("OpenAI -> Cursor converts tool_result blocks into sanitized XML user content", () => {
|
|
const result = buildCursorRequest(
|
|
"gpt-4o",
|
|
{
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [{ type: "tool_use", id: "call_1\nnoise", name: "read_file", input: {} }],
|
|
},
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{ type: "text", text: "Hello" },
|
|
{
|
|
type: "tool_result",
|
|
tool_use_id: "call_1",
|
|
content: [{ type: "text", text: "done\u0000" }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
false,
|
|
null
|
|
);
|
|
|
|
assert.equal(result.messages.length, 2);
|
|
assert.equal(result.messages[1].role, "user");
|
|
assert.match(result.messages[1].content, /Hello/);
|
|
assert.match(result.messages[1].content, /<tool_name>read_file<\/tool_name>/);
|
|
assert.match(result.messages[1].content, /<tool_call_id>call_1<\/tool_call_id>/);
|
|
assert.match(result.messages[1].content, /<result>done<\/result>/);
|
|
assert.equal(result.messages[1].content.includes("\u0000"), false);
|
|
});
|
|
|
|
test("OpenAI -> Cursor converts assistant tool_use blocks into assistant tool_calls", () => {
|
|
const result = buildCursorRequest(
|
|
"gpt-4o",
|
|
{
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{ type: "text", text: "Calling a tool" },
|
|
{ type: "tool_use", id: "call_2", name: "weather", input: { city: "Tokyo" } },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
false,
|
|
null
|
|
);
|
|
|
|
assert.deepEqual(result.messages, [
|
|
{
|
|
role: "assistant",
|
|
content: "Calling a tool",
|
|
tool_calls: [
|
|
{
|
|
id: "call_2",
|
|
type: "function",
|
|
function: { name: "weather", arguments: '{"city":"Tokyo"}' },
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("OpenAI -> Cursor converts tool role messages using remembered tool metadata", () => {
|
|
const result = buildCursorRequest(
|
|
"gpt-4o",
|
|
{
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
tool_calls: [
|
|
{
|
|
id: "call_3",
|
|
type: "function",
|
|
function: { name: "search_docs", arguments: "{}" },
|
|
},
|
|
],
|
|
},
|
|
{ role: "tool", tool_call_id: "call_3", content: "found it" },
|
|
],
|
|
},
|
|
false,
|
|
null
|
|
);
|
|
|
|
assert.equal(result.messages[1].role, "user");
|
|
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" } },
|
|
]);
|
|
});
|