Files
OmniRoute/tests/unit/cursor-agent-session.test.ts
payne 0594af6a6c feat(cursor): vision (image_url) input + tool-commit/output-constraint enhancements (#3104)
* feat(cursor): vision (image_url) input + tool-commit/output-constraint enhancements

Add image/vision input to the Cursor provider's agent.v1 endpoint, plus the
supporting prompt-engineering and resilience work developed alongside it.

Vision input
- Decode OpenAI `image_url` parts (base64 `data:` URIs and remote `http(s)` URLs)
  and inline them as `SelectedContext.selected_images[]` — field numbers pinned
  from the cursor-agent agent.v1 protobuf descriptor (SelectedImage.data oneof,
  uuid, optional Dimension, mime_type). Cross-checked against composer-api's shape.
- New `resolveCursorImages` helper: SSRF-guarded remote fetches via the repo's
  canonical `parseAndValidatePublicUrl` (always public-only for client URLs),
  <=1 MiB per image (pre-decode + streaming cap), `image/*` enforced, max 12
  images, sanitized `CursorImageError` (no stack/path leakage).
- `openai-to-cursor` translator now preserves `image_url` parts instead of
  dropping them; executor `buildRequest` resolves images and attaches them to
  the user turn. The no-image path is byte-identical to before (test-asserted).

Supporting cursor enhancements
- Tool-commit directive (raises composer-2.5 tool-call rate ~53% -> ~88%),
  `tool_choice` none/required/specific handling, and output constraints
  (`response_format` / `max_tokens` / `stop` surfaced as prompt instructions).
- `cursorSessionManager`: clear pending tool-call mappings on session close.
- `cursorVersionDetector`: export `FALLBACK_VERSION` as a single source of truth.

Tests & docs
- New unit suite for the image encoder + resolver (field layout, byte-identical
  no-image path, SSRF / oversize / bad-base64 / too-many rejections, sanitized
  error body), translator image-preservation tests, and live e2e tests
  (base64 + remote URL, gated on `CURSOR_E2E_TOKEN`).
- Documented `CURSOR_TOOL_DIRECTIVE` and `CURSOR_IMAGE_FETCH_TIMEOUT_MS` in
  `.env.example` and `docs/reference/ENVIRONMENT.md`.

* fix(cursor): address review — redirect SSRF, large-payload guard, stream OOM, case/NaN nits

Resolves the gemini-code-assist review on #3104:
- SSRF via redirect (critical): fetchImageBytes now uses redirect:"manual" and
  re-validates every hop through parseAndValidatePublicUrl, so a public URL can't
  30x-redirect to a private/link-local address. Bounded to 3 redirects.
- Large data URL (high): reject on raw payload length before the whitespace-strip
  regex, so an oversized data URL can't burn CPU.
- Stream read (high): readCapped consumes the body as an async iterable (Node
  Readable + Web Streams) or via getReader, capping mid-read; uncapped
  arrayBuffer() is only a last resort.
- data: scheme (medium): match case-insensitively (RFC 2397) while preserving the
  original payload.
- NaN timeouts (medium): CURSOR_IMAGE_FETCH_TIMEOUT_MS and CURSOR_STREAM_TIMEOUT_MS
  fall back to defaults when the env value isn't a positive integer.

Adds tests: redirect-to-private blocked, redirect-to-public followed, too-many-
redirects rejected, uppercase DATA: accepted.

* fix(cursor): defend image fetch against DNS-rebinding SSRF

Address the @codex review on #3104: parseAndValidatePublicUrl only checks the
hostname string, so a public-looking host that (re)resolves to a private /
link-local / metadata IP would still be fetched. Each hop now resolves the host
via dns.lookup({all:true}) and rejects if ANY answer is private (isPrivateHost),
before connecting. IP literals are skipped (already validated by the URL guard).

This narrows but doesn't fully close the TOCTOU window vs fetch's own
resolution; a connection-time IP filter on the shared outbound guard would
close it for every caller. Adds unit tests for the IP gate and a mocked
DNS-rebinding case (public host -> 127.0.0.1, fetch never reached).
2026-06-03 18:24:41 -03:00

272 lines
9.5 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import {
CursorSessionManager,
type CursorSession,
} from "../../open-sse/services/cursorSessionManager";
import { flattenMessages } from "../../open-sse/utils/cursorAgentProtobuf";
// ─── Test doubles for h2 ───────────────────────────────────────────────────
//
// We don't open real h2 connections in unit tests. Sessions hold opaque
// references that the manager only ever .close()s or .write()s through
// encodeExecMcpResult. A pair of stubs is enough.
type WriteCall = { kind: "write"; data: Buffer } | { kind: "close" };
function mockReq() {
const calls: WriteCall[] = [];
return {
req: {
write: (data: Buffer) => {
calls.push({ kind: "write", data });
return true;
},
close: () => {
calls.push({ kind: "close" });
},
} as unknown as import("node:http2").ClientHttp2Stream,
calls,
};
}
function mockClient() {
const closed = { value: false };
return {
client: {
close: () => {
closed.value = true;
},
} as unknown as import("node:http2").ClientHttp2Session,
closed,
};
}
// ─── flattenMessages: Phase 6 cold-resume support ──────────────────────────
test("flattenMessages handles role:'tool' messages", () => {
const out = flattenMessages([
{ role: "user", content: "what's the weather?" },
{
role: "assistant",
content: null,
tool_calls: [
{
id: "call_xyz",
type: "function",
function: { name: "get_weather", arguments: '{"city":"Paris"}' },
},
],
},
{ role: "tool", tool_call_id: "call_xyz", content: "sunny, 22C" },
]);
assert.match(out, /User: what's the weather\?/);
assert.match(
out,
/Assistant called tool get_weather \(call_xyz\) with arguments: \{"city":"Paris"\}/
);
assert.match(out, /Tool result \(call_xyz\): sunny, 22C/);
});
test("flattenMessages handles assistant with text + tool_calls in same message", () => {
const out = flattenMessages([
{ role: "user", content: "do x" },
{
role: "assistant",
content: "Let me check.",
tool_calls: [
{
id: "c1",
type: "function",
function: { name: "check", arguments: "{}" },
},
],
},
{ role: "tool", tool_call_id: "c1", content: "result" },
]);
assert.match(out, /Assistant: Let me check\./);
assert.match(out, /Assistant called tool check \(c1\) with arguments: \{\}/);
assert.match(out, /Tool result \(c1\): result/);
});
test("flattenMessages handles parallel tool_calls", () => {
const out = flattenMessages([
{ role: "user", content: "check both" },
{
role: "assistant",
content: null,
tool_calls: [
{ id: "c1", type: "function", function: { name: "tool_a", arguments: "{}" } },
{ id: "c2", type: "function", function: { name: "tool_b", arguments: "{}" } },
],
},
{ role: "tool", tool_call_id: "c1", content: "result_a" },
{ role: "tool", tool_call_id: "c2", content: "result_b" },
]);
assert.match(out, /tool_a \(c1\)/);
assert.match(out, /tool_b \(c2\)/);
assert.match(out, /Tool result \(c1\): result_a/);
assert.match(out, /Tool result \(c2\): result_b/);
});
test("flattenMessages keeps single-user fast path unchanged when no tool_calls", () => {
const out = flattenMessages([{ role: "user", content: "hi" }]);
assert.equal(out, "hi");
});
// ─── CursorSessionManager lifecycle ────────────────────────────────────────
test("CursorSessionManager.open registers a session under conversation_id", () => {
const m = new CursorSessionManager();
const { req } = mockReq();
const { client } = mockClient();
const session = m.open("conv-1", client, req, new Map());
assert.equal(m.size(), 1);
assert.ok(m.has("conv-1"));
assert.equal(session.conversationId, "conv-1");
assert.equal(session.state, "running");
});
test("CursorSessionManager.acquire returns undefined when no session", () => {
const m = new CursorSessionManager();
assert.equal(m.acquire("nope"), undefined);
});
test("CursorSessionManager.acquire returns undefined when session is still running", () => {
const m = new CursorSessionManager();
const { req } = mockReq();
const { client } = mockClient();
m.open("conv-2", client, req, new Map());
// open() leaves state="running"; acquire requires "awaiting_tool_result"
assert.equal(m.acquire("conv-2"), undefined);
});
test("CursorSessionManager.acquire returns the session after release(awaiting_tool_result)", () => {
const m = new CursorSessionManager();
const { req } = mockReq();
const { client } = mockClient();
const opened = m.open("conv-3", client, req, new Map());
m.release(opened, "awaiting_tool_result");
const acquired = m.acquire("conv-3");
assert.equal(acquired, opened);
assert.equal(acquired?.state, "running");
});
test("CursorSessionManager release(awaiting_tool_result) actively evicts after TTL", async () => {
const m = new CursorSessionManager({ idleTtlMs: 20 });
const { req } = mockReq();
const { client, closed } = mockClient();
const session = m.open("conv-ttl", client, req, new Map());
m.release(session, "awaiting_tool_result");
await new Promise((resolve) => setTimeout(resolve, 50));
assert.equal(m.size(), 0);
assert.ok(closed.value);
});
test("CursorSessionManager enforces a maximum retained session count", () => {
const m = new CursorSessionManager({ maxSessions: 1 });
const firstReq = mockReq();
const firstClient = mockClient();
m.open("conv-max-1", firstClient.client, firstReq.req, new Map());
const secondReq = mockReq();
const secondClient = mockClient();
m.open("conv-max-2", secondClient.client, secondReq.req, new Map());
assert.equal(m.size(), 1);
assert.equal(m.has("conv-max-1"), false);
assert.equal(m.has("conv-max-2"), true);
assert.ok(firstClient.closed.value);
});
test("CursorSessionManager.release(idle) closes the session", () => {
const m = new CursorSessionManager();
const { req, calls } = mockReq();
const { client, closed } = mockClient();
const session = m.open("conv-4", client, req, new Map());
m.release(session, "idle");
assert.equal(m.size(), 0);
assert.ok(closed.value);
assert.ok(calls.some((c) => c.kind === "close"));
});
test("CursorSessionManager.acquire evicts expired sessions", () => {
const m = new CursorSessionManager({ idleTtlMs: 10 });
const { req } = mockReq();
const { client, closed } = mockClient();
const session = m.open("conv-5", client, req, new Map());
m.release(session, "awaiting_tool_result");
// Manually backdate lastActivityTs to simulate idle.
session.lastActivityTs = Date.now() - 1000;
const acquired = m.acquire("conv-5");
assert.equal(acquired, undefined);
assert.equal(m.size(), 0);
assert.ok(closed.value);
});
test("CursorSessionManager.sendToolResult writes ExecMcpResult on the session's req", () => {
const m = new CursorSessionManager();
const { req, calls } = mockReq();
const { client } = mockClient();
const session = m.open("conv-6", client, req, new Map());
session.pendingToolCalls.set("call_x", {
execMsgId: 1,
execId: "exec-1",
toolName: "get_weather",
});
const ok = m.sendToolResult(session, "call_x", "sunny", false);
assert.equal(ok, true);
// Verify a write happened
const writes = calls.filter((c) => c.kind === "write");
assert.equal(writes.length, 1);
// The write should be a Connect-RPC frame containing "sunny" and "exec-1"
const data = (writes[0] as { kind: "write"; data: Buffer }).data;
assert.ok(data.includes(Buffer.from("sunny", "utf8")));
assert.ok(data.includes(Buffer.from("exec-1", "utf8")));
// Pending tool call was consumed
assert.equal(session.pendingToolCalls.has("call_x"), false);
});
test("CursorSessionManager.sendToolResult returns false when openAIToolCallId not pending", () => {
const m = new CursorSessionManager();
const { req } = mockReq();
const { client } = mockClient();
const session = m.open("conv-7", client, req, new Map());
const ok = m.sendToolResult(session, "unknown_id", "x", false);
assert.equal(ok, false);
});
test("CursorSessionManager.close clears unanswered pendingToolCalls", () => {
const m = new CursorSessionManager();
const { req } = mockReq();
const { client } = mockClient();
const session = m.open("conv-clear", client, req, new Map());
session.pendingToolCalls.set("call_unanswered", {
execMsgId: 1,
execId: "exec-1",
toolName: "get_weather",
});
m.close(session);
// close() drops the unanswered mapping so it isn't pinned on the dead session.
assert.equal(session.pendingToolCalls.size, 0);
assert.equal(m.size(), 0);
});
test("CursorSessionManager.open replaces an existing session for the same conversation", () => {
const m = new CursorSessionManager();
const r1 = mockReq();
const c1 = mockClient();
const session1 = m.open("conv-8", c1.client, r1.req, new Map());
m.release(session1, "awaiting_tool_result");
const r2 = mockReq();
const c2 = mockClient();
const session2 = m.open("conv-8", c2.client, r2.req, new Map());
// First session's client should be closed
assert.ok(c1.closed.value);
// Map only has one session; the new one
assert.equal(m.size(), 1);
assert.equal(m.acquire("conv-8"), undefined); // session2 is "running"
void session2;
});