Repository navigation
feat(cursor): vision (image_url) input + tool-commit/output-constraint enhancements - #3104
Conversation
…t 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`.
There was a problem hiding this comment.
Code Review
This pull request introduces vision support for Cursor executors by implementing image extraction, resolution, and security checks, alongside integrating tool-commit directives and output constraints. The code reviewer identified several critical security, performance, and robustness issues, including an SSRF vulnerability due to default redirect-following in fetch, a potential DoS/high CPU usage risk from regex operations on large data URLs, an OOM risk when handling Node.js streams, case-sensitivity bugs with the data: scheme check, and potential NaN timeout values if environment variables are malformed.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| async function fetchImageBytes(url: string): Promise<{ data: Buffer; mimeType: string }> { | ||
| let parsed: URL; | ||
| try { | ||
| parsed = parseAndValidatePublicUrl(url); | ||
| } catch (err) { | ||
| if (err instanceof OutboundUrlGuardError) { | ||
| // Map both invalid-scheme and blocked-private into a clean 400 without | ||
| // echoing the (possibly sensitive) URL back to the caller. | ||
| 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."); | ||
| } | ||
|
|
||
| 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 }); | ||
| } catch { | ||
| clearTimeout(timer); | ||
| throw new CursorImageError("Could not fetch the image URL."); | ||
| } | ||
| try { | ||
| 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); | ||
| } | ||
| } |
There was a problem hiding this comment.
The fetch call follows HTTP redirects by default. If the initial URL is validated by parseAndValidatePublicUrl, a malicious server can redirect the request to an internal/private IP address (e.g., http://127.0.0.1/admin), bypassing the SSRF guard. To prevent this, redirects should be handled manually with redirect: 'manual' and each redirect URL must be validated before fetching.
async function fetchImageBytes(url: string): Promise<{ data: Buffer; mimeType: string }> {
let currentUrl = url;
let redirects = 0;
const maxRedirects = 3;
while (redirects <= maxRedirects) {
let parsed: URL;
try {
parsed = parseAndValidatePublicUrl(currentUrl);
} 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.");
}
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 {
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get("location");
if (!location) {
throw new CursorImageError("Redirect location header is missing.");
}
currentUrl = new URL(location, parsed.toString()).toString();
redirects++;
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.");
}
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("Too many redirects.");
}There was a problem hiding this comment.
Agreed — real SSRF bypass (the guard only validated the initial URL). Fixed in 5699daa: fetchImageBytes now uses redirect: "manual" and re-validates every hop through parseAndValidatePublicUrl (bounded to 3 redirects). Added tests for redirect→private (blocked), redirect→public (followed), and too-many-redirects. Thanks for catching this.
| 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."); | ||
| } |
There was a problem hiding this comment.
Running payload.replace(/\s/g, '') on an arbitrarily large user-supplied data URL can cause high CPU usage and block the single-threaded Node.js event loop. Since the maximum allowed image size is 1 MiB, we can perform a cheap pre-check on the raw payload.length before executing any regex replacement or decoding.
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.");
}There was a problem hiding this comment.
Agreed. Added a raw payload.length > MAX_CURSOR_IMAGE_BYTES * 2 pre-check before the whitespace-strip regex so an oversized data URL can't burn CPU. Fixed in 5699daa.
| const body = response.body; | ||
| if (!body || typeof body.getReader !== "function") { | ||
| 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; | ||
| } |
There was a problem hiding this comment.
If body is a Node.js stream (e.g., in polyfilled or custom fetch environments), body.getReader will be undefined, causing the code to fall back to response.arrayBuffer(). This will buffer the entire response in memory before checking the size cap, posing an OOM risk for extremely large bodies. We can check if body is an async iterable (which both Node.js Readable streams and Web Streams support in modern Node.js) to consume it safely in chunks.
const body = response.body;
if (!body) {
return Buffer.alloc(0);
}
const chunks: Buffer[] = [];
let total = 0;
// Handle async iterable (Node.js Readable streams and Web Streams in modern Node)
if (typeof (body as any)[Symbol.asyncIterator] === "function") {
for await (const chunk of body as any) {
const buf = Buffer.from(chunk);
total += buf.length;
if (total > cap) {
throw new CursorImageError("Image input is too large (max 1 MiB). Resize and retry.");
}
chunks.push(buf);
}
return Buffer.concat(chunks, total);
}
if (typeof (body as any).getReader === "function") {
const reader = (body as any).getReader();
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
total += value.byteLength;
if (total > cap) {
throw new CursorImageError("Image input is too large (max 1 MiB). Resize and retry.");
}
chunks.push(Buffer.from(value));
}
}
} finally {
try {
await reader.cancel();
} catch {}
}
return Buffer.concat(chunks, total);
}
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;There was a problem hiding this comment.
Agreed. readCapped now consumes the body as an async iterable (Node Readable + Web Streams) or via getReader, capping mid-read; the uncapped arrayBuffer() path is only a last resort for exotic body shapes. Fixed in 5699daa.
| const { data, mimeType } = url.startsWith("data:") | ||
| ? decodeDataUrl(url) | ||
| : await fetchImageBytes(url); |
There was a problem hiding this comment.
The scheme check url.startsWith('data:') is case-sensitive. According to RFC 2397, the scheme is case-insensitive, so a data URL starting with DATA: is valid but would fail here. We should perform a case-insensitive check.
| const { data, mimeType } = url.startsWith("data:") | |
| ? decodeDataUrl(url) | |
| : await fetchImageBytes(url); | |
| const { data, mimeType } = url.toLowerCase().startsWith("data:") | |
| ? decodeDataUrl(url) | |
| : await fetchImageBytes(url); |
There was a problem hiding this comment.
Agreed — DATA: is valid per RFC 2397. Now matched case-insensitively while passing the original (un-lowercased) URL to the decoder so the base64 payload is preserved. Added an uppercase-DATA: test. Fixed in 5699daa.
| export const MAX_CURSOR_IMAGES = 12; | ||
|
|
||
| // Wall-clock cap for a single remote image fetch. | ||
| const IMAGE_FETCH_TIMEOUT_MS = parseInt(process.env.CURSOR_IMAGE_FETCH_TIMEOUT_MS || "15000", 10); |
There was a problem hiding this comment.
If process.env.CURSOR_IMAGE_FETCH_TIMEOUT_MS is set to an invalid string, parseInt will return NaN, which can cause unexpected behavior in setTimeout. We should add a defensive fallback check.
| const IMAGE_FETCH_TIMEOUT_MS = parseInt(process.env.CURSOR_IMAGE_FETCH_TIMEOUT_MS || "15000", 10); | |
| const parsedTimeout = parseInt(process.env.CURSOR_IMAGE_FETCH_TIMEOUT_MS || "15000", 10); | |
| const IMAGE_FETCH_TIMEOUT_MS = Number.isInteger(parsedTimeout) && parsedTimeout > 0 ? parsedTimeout : 15000; |
There was a problem hiding this comment.
Agreed. IMAGE_FETCH_TIMEOUT_MS now falls back to the 15000ms default unless the env value parses to a positive integer. Fixed in 5699daa.
| @@ -169,6 +266,14 @@ const debugLog = (...args: unknown[]) => { | |||
| // turns can be longer. Five minutes is generous but bounded. | |||
| const CURSOR_STREAM_TIMEOUT_MS = parseInt(process.env.CURSOR_STREAM_TIMEOUT_MS || "300000", 10); | |||
There was a problem hiding this comment.
If process.env.CURSOR_STREAM_TIMEOUT_MS is set to an invalid string, parseInt will return NaN, which can cause unexpected behavior in setTimeout. We should add a defensive fallback check.
const parsedStreamTimeout = parseInt(process.env.CURSOR_STREAM_TIMEOUT_MS || "300000", 10);
const CURSOR_STREAM_TIMEOUT_MS = Number.isInteger(parsedStreamTimeout) && parsedStreamTimeout > 0 ? parsedStreamTimeout : 300000;There was a problem hiding this comment.
Agreed. Applied the same positive-integer guard to CURSOR_STREAM_TIMEOUT_MS (falls back to 300000ms). Fixed in 5699daa.
…eam OOM, case/NaN nits Resolves the gemini-code-assist review on cheaper-inference#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.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5699daa0ca
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| response = await fetch(parsed.toString(), { | ||
| method: "GET", | ||
| signal: controller.signal, | ||
| redirect: "manual", | ||
| }); |
There was a problem hiding this comment.
Resolve image hosts before fetching them
For remote image_url inputs such as https://attacker.example/a.png where DNS resolves or rebinds to 127.0.0.1/169.254.169.254, this path validates only the literal URL hostname via parseAndValidatePublicUrl before handing the hostname to fetch. The guard in src/shared/network/outboundUrlGuard.ts checks url.hostname but does not resolve or pin the destination IP, so attacker-controlled DNS can still make the new client-supplied image fetch hit private or metadata services. Resolve and validate the address for each hop, or use a fetch helper that enforces that before opening the connection.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Good catch — agreed. parseAndValidatePublicUrl only checks the hostname string, so a public host that resolves/rebinds to a private or metadata IP would still be fetched. Fixed in f719fd7: 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 guarded). Since image_url is fully client-controlled, this is the highest-risk SSRF surface, so the extra hop is worth it. It narrows but doesn't fully close the TOCTOU window vs fetch's own resolution — a connection-time IP filter (e.g. ssrf-req-filter) on the shared outbound guard would close it for every caller, which I've noted as the complete follow-up. Added unit tests for the IP gate + a mocked rebinding case.
Address the @codex review on cheaper-inference#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).
|
Outstanding work, @payne0420! 🎉 This is a meticulous PR — encoding |
0594af6
into
cheaper-inference:release/v3.8.9
….ts (dynamic tools) The Lint job's check:any-budget:t11 (string-blind /\bany\b/ regex) failed on: - open-sse/executors/cursor.ts: the WORD 'any' in #3104's tool-commit/output- constraint prompt strings — zero real TS `any` in the file. - open-sse/mcp-server/server.ts: 3 `(toolDef: any)` in dynamic memory/skill/ compression tool-registration loops (#3077), guarded by existing @ts-ignore. Both are v3.8.9-introduced and benign; set the per-file baseline to the count.
…t enhancements (cheaper-inference#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 cheaper-inference#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 cheaper-inference#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).
…ors hall Adds entries for cheaper-inference#3097, cheaper-inference#3101 (deepseek-web cheaper-inference#2942/cheaper-inference#2820), cheaper-inference#3104, cheaper-inference#3105, cheaper-inference#3107, cheaper-inference#3109, cheaper-inference#3111, cheaper-inference#3113, cheaper-inference#3115, cheaper-inference#3122, cheaper-inference#3125, cheaper-inference#3127, cheaper-inference#3129, plus a Contributors section crediting all v3.8.9 contributors. Stamps the 3.8.9 release date.
….ts (dynamic tools) The Lint job's check:any-budget:t11 (string-blind /\bany\b/ regex) failed on: - open-sse/executors/cursor.ts: the WORD 'any' in cheaper-inference#3104's tool-commit/output- constraint prompt strings — zero real TS `any` in the file. - open-sse/mcp-server/server.ts: 3 `(toolDef: any)` in dynamic memory/skill/ compression tool-registration loops (cheaper-inference#3077), guarded by existing @ts-ignore. Both are v3.8.9-introduced and benign; set the per-file baseline to the count.
…t enhancements (cheaper-inference#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 cheaper-inference#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 cheaper-inference#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).
…ors hall Adds entries for cheaper-inference#3097, cheaper-inference#3101 (deepseek-web cheaper-inference#2942/cheaper-inference#2820), cheaper-inference#3104, cheaper-inference#3105, cheaper-inference#3107, cheaper-inference#3109, cheaper-inference#3111, cheaper-inference#3113, cheaper-inference#3115, cheaper-inference#3122, cheaper-inference#3125, cheaper-inference#3127, cheaper-inference#3129, plus a Contributors section crediting all v3.8.9 contributors. Stamps the 3.8.9 release date.
….ts (dynamic tools) The Lint job's check:any-budget:t11 (string-blind /\bany\b/ regex) failed on: - open-sse/executors/cursor.ts: the WORD 'any' in cheaper-inference#3104's tool-commit/output- constraint prompt strings — zero real TS `any` in the file. - open-sse/mcp-server/server.ts: 3 `(toolDef: any)` in dynamic memory/skill/ compression tool-registration loops (cheaper-inference#3077), guarded by existing @ts-ignore. Both are v3.8.9-introduced and benign; set the per-file baseline to the count.
…t enhancements (cheaper-inference#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 cheaper-inference#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 cheaper-inference#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).
…ors hall Adds entries for cheaper-inference#3097, cheaper-inference#3101 (deepseek-web cheaper-inference#2942/cheaper-inference#2820), cheaper-inference#3104, cheaper-inference#3105, cheaper-inference#3107, cheaper-inference#3109, cheaper-inference#3111, cheaper-inference#3113, cheaper-inference#3115, cheaper-inference#3122, cheaper-inference#3125, cheaper-inference#3127, cheaper-inference#3129, plus a Contributors section crediting all v3.8.9 contributors. Stamps the 3.8.9 release date.
….ts (dynamic tools) The Lint job's check:any-budget:t11 (string-blind /\bany\b/ regex) failed on: - open-sse/executors/cursor.ts: the WORD 'any' in cheaper-inference#3104's tool-commit/output- constraint prompt strings — zero real TS `any` in the file. - open-sse/mcp-server/server.ts: 3 `(toolDef: any)` in dynamic memory/skill/ compression tool-registration loops (cheaper-inference#3077), guarded by existing @ts-ignore. Both are v3.8.9-introduced and benign; set the per-file baseline to the count.
Summary
Adds image / vision input to OmniRoute's Cursor provider (the
agent.v1.AgentService/Runendpoint), plus the supporting prompt-engineering and resilience work developed alongside it. Multimodal OpenAI requests withimage_urlparts (base64 or remote URL) now reach a vision-capable Cursor model instead of being silently dropped.Vision input
image_urlparts are decoded and inlined asSelectedContext.selected_images[]on the user turn. Field numbers were pinned from the cursor-agentagent.v1protobuf descriptor (SelectedImage.dataoneof = inline bytes, plusuuid, optionalDimension,mime_type), cross-checked against the composer-api encoder shape. The no-image path is byte-identical to before (test-asserted).resolveCursorImages) — remote fetches go through the repo's canonicalparseAndValidatePublicUrlSSRF guard (always public-only for client-supplied URLs); each image is capped at ≤1 MiB (pre-decode + streaming);image/*content-type enforced; max 12 images per request; all failures return sanitizedCursorImageErrors (no stack/path leakage).openai-to-cursornow preservesimage_urlparts (was dropping them); the executor'sbuildRequestresolves images and attaches them to the user turn.Supporting Cursor enhancements (included)
tool_choicenone/required/specific handling, and output constraints (response_format/max_tokens/stopsurfaced as prompt instructions).cursorSessionManager: clear pending tool-call mappings on session close.cursorVersionDetector: exportFALLBACK_VERSIONas a single source of truth.Verification
Validated live against the real endpoint: solid red/green/blue PNGs are correctly identified by
gpt-5.2,claude-opus-4-8-low, andcomposer-2.5(composer is vision-capable); remote-URL fetch and multi-image messages confirmed.image_urlpreservation (object + shorthand forms).CURSOR_E2E_TOKEN) — base64-image + remote-URL image tests.Notes
.env.example+docs/reference/ENVIRONMENT.md:CURSOR_IMAGE_FETCH_TIMEOUT_MS,CURSOR_TOOL_DIRECTIVE.release/v3.8.9.