Skip to content

feat(cursor): vision (image_url) input + tool-commit/output-constraint enhancements - #3104

Merged
diegosouzapw merged 3 commits into
cheaper-inference:release/v3.8.9from
payne0420:feat/cursor-image-input
Jun 3, 2026
Merged

diegosouzapw merged 3 commits into
cheaper-inference:release/v3.8.9from
payne0420:feat/cursor-image-input

Conversation

@payne0420

Copy link
Copy Markdown
Contributor

Summary

Adds image / vision input to OmniRoute's Cursor provider (the agent.v1.AgentService/Run endpoint), plus the supporting prompt-engineering and resilience work developed alongside it. Multimodal OpenAI requests with image_url parts (base64 or remote URL) now reach a vision-capable Cursor model instead of being silently dropped.

Vision input

  • Encoder — image_url parts are decoded and inlined as SelectedContext.selected_images[] on the user turn. Field numbers were pinned from the cursor-agent agent.v1 protobuf descriptor (SelectedImage.data oneof = inline bytes, plus uuid, optional Dimension, mime_type), cross-checked against the composer-api encoder shape. The no-image path is byte-identical to before (test-asserted).
  • Security (resolveCursorImages) — remote fetches go through the repo's canonical parseAndValidatePublicUrl SSRF 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 sanitized CursorImageErrors (no stack/path leakage).
  • Translator/executor — openai-to-cursor now preserves image_url parts (was dropping them); the executor's buildRequest resolves images and attaches them to the user turn.

Supporting Cursor enhancements (included)

  • 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.

Verification

Validated live against the real endpoint: solid red/green/blue PNGs are correctly identified by gpt-5.2, claude-opus-4-8-low, and composer-2.5 (composer is vision-capable); remote-URL fetch and multi-image messages confirmed.

  • Unit — image encoder field layout, byte-identical no-image path, resolver rejections (SSRF / oversize / bad-base64 / too-many), sanitized error body.
  • Translator — image_url preservation (object + shorthand forms).
  • E2E (gated on CURSOR_E2E_TOKEN) — base64-image + remote-URL image tests.

Notes

  • New env vars documented in .env.example + docs/reference/ENVIRONMENT.md: CURSOR_IMAGE_FETCH_TIMEOUT_MS, CURSOR_TOOL_DIRECTIVE.
  • Targets release/v3.8.9.

…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`.
@payne0420
payne0420 requested a review from diegosouzapw as a code owner June 3, 2026 11:50

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread open-sse/utils/cursorImages.ts Outdated
Comment on lines +97 to +143
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);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-critical critical

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.");
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +76 to +81
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.");
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

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.");
  }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread open-sse/utils/cursorImages.ts Outdated
Comment on lines +151 to +158
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;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread open-sse/utils/cursorImages.ts Outdated
Comment on lines +201 to +203
const { data, mimeType } = url.startsWith("data:")
? decodeDataUrl(url)
: await fetchImageBytes(url);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
const { data, mimeType } = url.startsWith("data:")
? decodeDataUrl(url)
: await fetchImageBytes(url);
const { data, mimeType } = url.toLowerCase().startsWith("data:")
? decodeDataUrl(url)
: await fetchImageBytes(url);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread open-sse/utils/cursorImages.ts Outdated
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. IMAGE_FETCH_TIMEOUT_MS now falls back to the 15000ms default unless the env value parses to a positive integer. Fixed in 5699daa.

Comment thread open-sse/executors/cursor.ts Outdated
@@ -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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@payne0420

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +142 to +146
response = await fetch(parsed.toString(), {
method: "GET",
signal: controller.signal,
redirect: "manual",
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).
@diegosouzapw

Copy link
Copy Markdown
Collaborator

Outstanding work, @payne0420! 🎉 This is a meticulous PR — encoding image_url parts as SelectedContext.selected_images[] with the field numbers pinned from the agent.v1 descriptor, the byte-identical no-image path (asserted in tests), the tool-commit directive bumping composer-2.5's tool-call rate, and the tool_choice/output-constraint handling. The security hardening (SSRF guard via parseAndValidatePublicUrl + DNS-rebinding check, 1 MiB pre-decode cap, image-count cap, protobuf length-overrun guard) and the comprehensive cursor-image-input.test.ts coverage made this easy to trust. Merged into release/v3.8.9 for the next release. Thank you for the depth here!

@diegosouzapw
diegosouzapw merged commit 0594af6 into cheaper-inference:release/v3.8.9 Jun 3, 2026
2 checks passed
@diegosouzapw diegosouzapw mentioned this pull request Jun 3, 2026
diegosouzapw added a commit that referenced this pull request Jun 3, 2026
…ors hall

Adds entries for #3097, #3101 (deepseek-web #2942/#2820), #3104, #3105, #3107,
#3109, #3111, #3113, #3115, #3122, #3125, #3127, #3129, plus a Contributors
section crediting all v3.8.9 contributors. Stamps the 3.8.9 release date.
diegosouzapw added a commit that referenced this pull request Jun 3, 2026
….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.
HouMinXi pushed a commit to HouMinXi/OmniRoute that referenced this pull request Aug 2, 2026
…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).
HouMinXi pushed a commit to HouMinXi/OmniRoute that referenced this pull request Aug 2, 2026
….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.
Poid-ZA pushed a commit to Poid-ZA/OmniRoute that referenced this pull request Aug 5, 2026
…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).
Poid-ZA pushed a commit to Poid-ZA/OmniRoute that referenced this pull request Aug 5, 2026
….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.
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
…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).
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
….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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants