diff --git a/changelog.d/features/cursor-agent-image-provider.md b/changelog.d/features/cursor-agent-image-provider.md new file mode 100644 index 0000000000..84646dc44e --- /dev/null +++ b/changelog.d/features/cursor-agent-image-provider.md @@ -0,0 +1 @@ +- feat(sse): add Cursor plan image generation via Agent CLI (`IMAGE_PROVIDERS.cursor`, format `cursor-agent-image`), reusing the chat Cursor OAuth connection diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 96001706ef..333d269ddc 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -447,7 +447,7 @@ "_rebaseline_2026_08_11_v3850_merge_storm_provider_registry": "DRIFT do merge-storm 2026-08-11 (99 PRs mergeados no release/v3.8.50). AddApiKeyModal.tsx (PR #8949 ChatGPT Web provider) e useProviderConnections.ts/ModelSelectModal.tsx (PRs #9011 combo test-all, #9499 image combos) = UI nova legitima acima do cap; gateways.ts = god-file de catalogo de providers que cresceu com PRs #9009/#9421/#9468/#9594 (qualquer split arriscaria corromper o merge de novo — o proprio PR #9421 quebrou o arquivo); bridge.ts (PR #8949) = ponte Chromium vendored; proxyFetch.ts 1207->1220 = drift herdado de merges. Owner autorizou rebaseline com anotacao (2026-08-11).", "src/lib/modelCapabilities.ts": 1006, "src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 1014, - "open-sse/config/imageRegistry.ts": 1019, + "open-sse/config/imageRegistry.ts": 1033, "src/sse/handlers/chatHelpers.ts": 1017, "src/shared/middleware/chatBodyAdmission.ts": 1005 }, @@ -615,5 +615,6 @@ "_rebaseline_2026_08_12_modelcapabilities_snapshot_routing": "Base-reds round 3 (#9985): modelCapabilities.ts crossed the new-file cap at 1006 (+~10) when the context/max-input-token override lookups were routed through the #9199 bulk snapshot (fixing 323 per-model SQLite reads per catalog prepare — auto-combo-context-advertising guard); cohesive change at the existing resolution chokepoints, not extractable. Covered by tests/unit/auto-combo-context-advertising.test.ts + model-capability-resolution-snapshot-9199.test.ts.", "_rebaseline_2026_08_14_imagetotext_servicekinds": "Image-to-Text category (#10275/#10291): gateways.ts grew 1250→1255 by data lines only — the serviceKinds: [\"llm\", \"imageToText\"] declarations on the openrouter and chutes catalog entries, plus the 3-line comment recording why chutes needs no static dots.ocr entry (passthroughModels discovery). No new logic or branching; the file is a provider catalog of declarative metadata. Splitting a catalog for five lines would be worse than the growth (semantic-families rule).", "_rebaseline_2026_08_18_imageregistry_merge_train": "merge-train 2026-08-18 (owner-authorized, /merge-prs batch of 84): open-sse/config/imageRegistry.ts crossed the 1000-line new-file cap for the first time purely from combining three independent, already-legitimate provider registrations boarded in the same local merge-train — #10542 (aihorde optional-key image catalog), #10494 (gemini-web image generation), #10594 (freepik/magnific provider rename + validation). 996 on release tip -> 1019 on the train tip. Each PR individually adds a small, additive IMAGE_PROVIDERS registry entry at the existing chokepoint; none crosses the cap alone. Not modularized as part of this train's gate fix (out of scope for a merge reconciliation, not a feature change). Covered by each PR's own focused tests (aihorde-image-catalog/generation, gemini-web image tests, freepik/magnific provider tests).", - "_rebaseline_2026_08_20_v3850_merge_train_batch1": "Merge-train batch1 (2026-08-19/20, 30 PRs boarded onto release/v3.8.50): gateways.ts 1255->1268 = PR #10722 (Token Kiosk OpenAI-compatible provider gateway catalog entry, +13 declarative lines, same god-file no-split rationale as prior gateways.ts rebaselines); chatHelpers.ts (uncapped, not previously frozen) new 1017 = PR #10797 (relay/bifrost error normalization, +23/-2, own-PR growth, existing file already near cap from accumulated chokepoint wiring per its own rebaseline history above); chatBodyAdmission.ts (uncapped) new 1005 = pre-existing base-red on the pure release tip (1004>1000 before this train boarded anything, no PR in this batch touches this file) — frozen here at its current size, not authorizing further growth. Owner-authorized rebaseline (2026-08-19 merge-prs session)." + "_rebaseline_2026_08_20_v3850_merge_train_batch1": "Merge-train batch1 (2026-08-19/20, 30 PRs boarded onto release/v3.8.50): gateways.ts 1255->1268 = PR #10722 (Token Kiosk OpenAI-compatible provider gateway catalog entry, +13 declarative lines, same god-file no-split rationale as prior gateways.ts rebaselines); chatHelpers.ts (uncapped, not previously frozen) new 1017 = PR #10797 (relay/bifrost error normalization, +23/-2, own-PR growth, existing file already near cap from accumulated chokepoint wiring per its own rebaseline history above); chatBodyAdmission.ts (uncapped) new 1005 = pre-existing base-red on the pure release tip (1004>1000 before this train boarded anything, no PR in this batch touches this file) — frozen here at its current size, not authorizing further growth. Owner-authorized rebaseline (2026-08-19 merge-prs session).", + "_rebaseline_2026_08_20_8338_cursor_image_provider": "PR (reimplementation of #8338, @valvesss): imageRegistry.ts 1019->1033 = new cursor IMAGE_PROVIDERS entry (Cursor plan image generation via Agent CLI), +14 lines of declarative provider metadata. Same god-registry no-split rationale as prior imageRegistry/gateways rebaselines." } \ No newline at end of file diff --git a/docs/getting-started/PROVIDERS-GUIDE.md b/docs/getting-started/PROVIDERS-GUIDE.md index d54b656a8a..65de3c63ea 100644 --- a/docs/getting-started/PROVIDERS-GUIDE.md +++ b/docs/getting-started/PROVIDERS-GUIDE.md @@ -239,3 +239,7 @@ Go to Providers → click on the provider → click **Disconnect**. - **[Free Tiers Guide](./FREE-TIERS-GUIDE.md)** — Get free AI with no credit card - **[Troubleshooting](../guides/TROUBLESHOOTING.md)** — Fix common issues - **[Provider Reference](../reference/PROVIDER_REFERENCE.md)** — Full list of 226 providers + +## Cursor images + +Cursor plan images use `IMAGE_PROVIDERS.cursor` (`cursor-agent-image`). See [CURSOR_IMAGE.md](../providers/CURSOR_IMAGE.md). diff --git a/docs/providers/CURSOR_IMAGE.md b/docs/providers/CURSOR_IMAGE.md new file mode 100644 index 0000000000..a620c4de79 --- /dev/null +++ b/docs/providers/CURSOR_IMAGE.md @@ -0,0 +1,75 @@ +--- +title: "Cursor Image Generation" +version: 3.8.49 +lastUpdated: 2026-07-23 +--- + +# Cursor Image Generation + +OmniRoute exposes Cursor plan **image generation** on `POST /v1/images/generations` through the same provider id as chat: `cursor` (alias `cu`). + +| Field | Value | +|-------|--------| +| `IMAGE_PROVIDERS` id | `cursor` | +| Format | `cursor-agent-image` | +| Auth | Same OAuth / API-key connection as chat (`provider_connections.provider = "cursor"`) | +| Models | `cursor/auto`, `cursor/composer-2`, `cursor/composer-2.5` | + +## Why the Agent CLI + +Cursor chat in OmniRoute uses `agent.v1.AgentService/Run` (protobuf). That path **rejects** built-in client tools (shell, write, …). Image generation is a Cursor-native tool executed by the **`agent` CLI** against the seat. The image handler therefore spawns `agent` with a locked prompt and a per-request temp workspace (same shape as community seat bridges), then returns OpenAI-compatible `b64_json`. + +## Access restriction (Hard Rules #15 + #17) + +This is the only `IMAGE_PROVIDERS` format that spawns a child process (the `agent` +binary). Because `POST /v1/images/generations` is shared by ~40 other, non-spawning +image providers that remote callers legitimately use, the whole route is **not** +classified `LOCAL_ONLY` — instead `handleCursorAgentImageGeneration` enforces its own +gate using the trusted `AUTHZ_HEADER_PEER_LOCALITY` verdict the authz pipeline stamps +on every request (from the real TCP peer, never the spoofable `Host` header): only +`loopback` and `lan` callers may reach the spawn; everything else (including a leaked +API key replayed over a public tunnel) gets `403` before any credential lookup or +process spawn happens. See `src/server/authz/policies/management.ts` for the same +policy applied to the rest of the `LOCAL_ONLY` tier. + +## Concurrency gate is module-level (single-instance limitation) + +`CURSOR_IMG_MAX_CONCURRENT` is enforced by an in-memory counter/queue scoped to the +Node module instance (`open-sse/handlers/imageGeneration/providers/cursorAgentImage.ts`). +It correctly limits concurrent `agent` spawns within one OmniRoute process, but does +**not** coordinate across multiple processes/instances sharing the same Cursor seat +(e.g. a multi-replica deployment) — each instance enforces its own independent limit. +For a single-instance deployment (the default) this is exact; horizontally scaled +deployments should keep `CURSOR_IMG_MAX_CONCURRENT` conservative per instance or route +Cursor image traffic to a single instance. + +## Requirements + +1. A connected Cursor account in the dashboard (OAuth or `crsr_…` API key). +2. The Cursor Agent binary available to the OmniRoute process: + - env `CURSOR_AGENT_BIN=/path/to/agent`, or + - `~/.local/bin/agent`, or + - `providerSpecificData.agentBin` on the Cursor connection. + +Optional tuning: + +| Env | Default | Meaning | +|-----|---------|---------| +| `CURSOR_IMG_TIMEOUT_MS` | `210000` | Per-image wall clock | +| `CURSOR_IMG_MAX_CONCURRENT` | `2` | Shared-seat concurrency gate | +| `CURSOR_IMG_MODEL` | (request model / `auto`) | Override CLI `--model` | + +## Example + +```bash +curl -sS https:///v1/images/generations \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"model":"cursor/auto","prompt":"a lantern in fog","size":"1024x1024"}' +``` + +Generation typically takes 1–2 minutes. Prefer an internal network path; edge proxies with ~100s timeouts will fail. + +## LiteLLM + +Register an image model with `mode: image_generation`, `api_base: http://omniroute:20128/v1`, and `model: openai/cursor/auto` (or bare `cursor/auto` depending on your LiteLLM version). diff --git a/docs/reference/PROVIDER_REFERENCE.md b/docs/reference/PROVIDER_REFERENCE.md index 440088ed3d..f6b2568af5 100644 --- a/docs/reference/PROVIDER_REFERENCE.md +++ b/docs/reference/PROVIDER_REFERENCE.md @@ -62,8 +62,8 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `clinepass` | `cp` | ClinePass | OAuth | [link](https://cline.bot/cline-pass) | ClinePass is Cline's $9.99/mo subscription bundling 10 open coding models. Sign in with your Cline account (same login as the Cline CLI/IDE), or paste a direct ClinePass API key (app.cline.bot → Settings → API Keys). A ClinePass subscription unlocks the cline-pass/* models. Reuses the Cline WorkOS OAuth flow. | | `codebuddy-cn` | `cbcn` | CodeBuddy CN | OAuth | [link](https://copilot.tencent.com) | Tencent CodeBuddy CN (copilot.tencent.com). Sign in via the official CLI device-code flow, or paste a direct API key (sent as Authorization: Bearer). Catalog: GLM / Kimi / MiniMax / DeepSeek / Hunyuan. | | `codex` | `cx` | OpenAI Codex | OAuth | — | — | -| `cursor` | `cu` | Cursor IDE | OAuth | — | — | -| `devin-cli` | `dv` | Devin CLI | OAuth | [link](https://cli.devin.ai) | Requires the Devin CLI binary. Run `devin auth login` to authenticate, or provide your WINDSURF_API_KEY. Install: https://cli.devin.ai | +| `cursor` | `cu` | Cursor IDE | OAuth, image | — | Image via Agent CLI (`CURSOR_AGENT_BIN`); same seat as chat | +| `devin-cli` | `dv` | Devin CLI (Official) | OAuth | [link](https://cli.devin.ai) | Requires the Devin CLI binary. Run `devin auth login` to authenticate, or provide your WINDSURF_API_KEY. Install: https://cli.devin.ai | | `devin-desktop` | — | Devin Desktop | OAuth | [link](https://devin.ai) | Paste an existing Devin API key from an authenticated Devin session. Key export availability and steps vary by Devin version and account. | | `ghe-copilot` | `ghe-copilot` | GitHub Enterprise Copilot | OAuth | — | Enter your GHE instance URL (e.g., https://ghe.company.com) in provider settings, then authenticate via device flow. | | `github` | `gh` | GitHub Copilot | OAuth | — | — | diff --git a/open-sse/config/imageRegistry.ts b/open-sse/config/imageRegistry.ts index 17ce7de90e..8defd6d8a8 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -268,6 +268,25 @@ export const IMAGE_PROVIDERS: Record = { supportedSizes: ["1024x1024", "1024x1536", "1536x1024"], }, + // Cursor plan image generation via the Agent CLI native `generateImage` tool. + // Reuses the same OAuth/API-key connection as chat (`provider: "cursor"`). + // Requires the `agent` binary (CURSOR_AGENT_BIN) — see cursorAgentImage handler. + cursor: { + id: "cursor", + alias: "cu", + // Sentinel: execution is local Agent CLI, not an HTTP image API. + baseUrl: "agent://cursor-agent", + authType: "oauth", + authHeader: "bearer", + format: "cursor-agent-image", + models: [ + { id: "auto", name: "Cursor Auto (Image)" }, + { id: "composer-2", name: "Composer 2 (Image)" }, + { id: "composer-2.5", name: "Composer 2.5 (Image)" }, + ], + supportedSizes: ["1024x1024", "1024x1792", "1792x1024", "1024x1536", "1536x1024"], + }, + "microsoft-designer-web": { id: "microsoft-designer-web", alias: "msdesigner", diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index a8452f0245..5d50f125f9 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -54,6 +54,7 @@ import { handleGeminiWebImageGeneration } from "./imageGeneration/providers/gemi import { handleNvidiaNimImageGeneration } from "./imageGeneration/providers/nvidiaNim.ts"; import { handleSegmindImageGeneration } from "./imageGeneration/providers/segmind.ts"; import { handleDesignerWebImageGeneration } from "./imageGeneration/providers/designerWeb.ts"; +import { handleCursorAgentImageGeneration } from "./imageGeneration/providers/cursorAgentImage.ts"; import { handleMinimaxImageGeneration } from "./imageGeneration/providers/minimax.ts"; import { handleAdobeFireflyImageGeneration } from "./imageGeneration/providers/adobeFirefly.ts"; import { handleAlibabaImageGeneration } from "./imageGeneration/providers/alibabaImage.ts"; @@ -300,6 +301,10 @@ const FAL_PRESET_SIZES = { * @param {object} options.credentials - Provider credentials { apiKey, accessToken } * @param {object} options.log - Logger * @param {string} [options.resolvedProvider] - Pre-resolved provider ID (from route layer custom model resolution) + * @param {string|null} [options.peerLocality] - Trusted "loopback"|"lan"|"remote" verdict + * forwarded from `AUTHZ_HEADER_PEER_LOCALITY` (src/server/authz/headers.ts). Only consumed by + * spawn-capable providers (e.g. cursor-agent-image) to enforce Hard Rules #15/#17 without + * loopback-gating the whole route for every non-spawning image provider. */ export async function handleImageGeneration({ body, @@ -308,6 +313,7 @@ export async function handleImageGeneration({ resolvedProvider = null, signal = null, clientHeaders = null, + peerLocality = null, }) { let provider, model; @@ -518,6 +524,18 @@ export async function handleImageGeneration({ }); } + if (providerConfig.format === "cursor-agent-image") { + return handleCursorAgentImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, + peerLocality, + }); + } + if (providerConfig.format === "designer-web") { return handleDesignerWebImageGeneration({ model, diff --git a/open-sse/handlers/imageGeneration/providers/cursorAgentImage.ts b/open-sse/handlers/imageGeneration/providers/cursorAgentImage.ts new file mode 100644 index 0000000000..a05b7ef854 --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/cursorAgentImage.ts @@ -0,0 +1,488 @@ +/** + * Cursor Agent image generation — OpenAI `/v1/images/generations` backed by the + * Cursor Agent CLI's native `generateImage` tool (real diffusion, not SVG). + * + * Why CLI (not AgentService/Run): OmniRoute's Cursor chat executor talks to + * `agent.v1.AgentService/Run` over protobuf and **rejects** built-in tools + * (shell/write/…). Image generation is a Cursor-native client tool that the + * `agent` binary executes locally against the seat. Spawning the CLI with a + * locked prompt + per-request workspace mirrors the proven seat bridge shape + * and reuses the same `provider_connections` row as chat (`provider: "cursor"`). + * + * Auth: `credentials.accessToken` / `apiKey` from the Cursor OAuth (or API-key) + * connection. Tokens matching `crsr_…` are exported as `CURSOR_API_KEY`; other + * session JWTs as `CURSOR_AUTH_TOKEN`. The `account::token` composite used by + * the chat executor is normalized the same way (`split("::")[1]`). + * + * Binary: `CURSOR_AGENT_BIN` → `providerSpecificData.agentBin` → PATH / default + * shim under `~/.local/bin/agent`. Missing binary → HTTP 501 with install hint. + */ + +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; +import { sanitizeErrorMessage } from "../../../utils/error.ts"; +import { saveImageErrorResult, saveImageSuccessResult } from "../../imageGeneration.ts"; +import { IMAGE_PROVIDERS } from "../../../config/imageRegistry.ts"; + +export const CURSOR_AGENT_IMAGE_FORMAT = "cursor-agent-image"; + +const DEFAULT_TIMEOUT_MS = 210_000; +const DEFAULT_MAX_CONCURRENT = 2; +const DEFAULT_MODEL = "auto"; +const MAX_N = 4; + +// Upper bound on a caller-supplied `timeout_ms`. The Cursor seat is shared and +// CURSOR_IMG_MAX_CONCURRENT defaults to only 2 slots, so a huge per-request +// timeout must not hog a slot and starve every other caller. +const MAX_TIMEOUT_MS = 300_000; + +// Models the Agent CLI `--model` argv may receive — kept in sync with the +// registry entry (auto | composer-2 | composer-2.5). The request `model` is +// untrusted input forwarded straight into a spawned CLI, so we mirror the +// auggie executor: anything outside this set (unknown model, or a flag-shaped +// value like "--foo" / "-x") is clamped to DEFAULT_MODEL and never reaches argv. +const CURSOR_IMAGE_MODEL_ALLOWLIST: ReadonlySet = new Set( + (IMAGE_PROVIDERS.cursor?.models ?? []).map((m) => m.id) +); + +/** Clamp a model candidate to the allowlist; unknown/flag-shaped → "auto". */ +export function resolveCursorImageModel(candidate: unknown): string { + const requested = typeof candidate === "string" ? candidate.trim() : ""; + return CURSOR_IMAGE_MODEL_ALLOWLIST.has(requested) ? requested : DEFAULT_MODEL; +} + +const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); +const JPEG_MAGIC = Buffer.from([0xff, 0xd8, 0xff]); + +/** + * Localities allowed to trigger the `agent` binary spawn below (Hard Rules + * #15 + #17). `/v1/images/generations` is a normal remote-reachable inference + * route shared by ~40 image providers that only proxy HTTP — the ONLY branch + * here that spawns a child process is this one, so the whole route cannot be + * classified in `LOCAL_ONLY_API_PREFIXES` (routeGuard.ts) without blocking + * every other, non-spawning image provider for remote callers. Instead this + * handler enforces its OWN loopback/LAN gate using the trusted locality + * verdict the authz pipeline already stamps on every request + * (`AUTHZ_HEADER_PEER_LOCALITY`, src/server/authz/headers.ts, computed from + * the real TCP peer IP — never the spoofable Host header). Mirrors the + * loopback-or-private-LAN policy `managementPolicy` applies to every other + * LOCAL_ONLY route (src/server/authz/policies/management.ts). + */ +const SPAWN_ALLOWED_LOCALITIES = new Set(["loopback", "lan"]); + +/** Locked instruction — ingress callers can only trigger image gen, never a shell. */ +export function buildCursorAgentImagePrompt(userPrompt: string, outPath: string, size?: unknown): string { + const sizeHint = + typeof size === "string" && size.trim() ? ` Target size/aspect: ${size.trim()}.` : ""; + return [ + "You have a native image-generation tool. Use it to generate ONE image.", + "Do NOT write code, do NOT hand-author SVG, do NOT install packages — use your built-in image generation.", + `Image to generate: ${userPrompt}.${sizeHint}`, + `Save the resulting image to exactly this path: ${outPath}.`, + "When the file exists at that exact path, reply with only the word DONE.", + ].join(" "); +} + +/** Strip OmniRoute `account::token` composites the same way CursorExecutor does. */ +export function normalizeCursorSeatToken(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) return trimmed; + return trimmed.includes("::") ? trimmed.split("::").slice(1).join("::").trim() || trimmed : trimmed; +} + +/** + * Map a Cursor connection token into the env vars the Agent CLI reads. + * Prefer API keys (`crsr_…`) as `CURSOR_API_KEY`; otherwise session JWT → `CURSOR_AUTH_TOKEN`. + */ +export function buildCursorAgentAuthEnv(token: string): Record { + const clean = normalizeCursorSeatToken(token); + if (clean.startsWith("crsr_")) { + return { CURSOR_API_KEY: clean }; + } + return { CURSOR_AUTH_TOKEN: clean }; +} + +export function resolveCursorAgentBin(override?: string | null): string | null { + // Explicit connection override wins even when the path is missing — the handler + // returns 501 so operators see a clear misconfiguration instead of a silent fallback. + if (typeof override === "string" && override.trim()) { + return override.trim(); + } + const envBin = process.env.CURSOR_AGENT_BIN?.trim(); + if (envBin) return envBin; + + const defaultShim = join(homedir(), ".local", "bin", "agent"); + if (existsSync(defaultShim)) return defaultShim; + + // Last resort: bare `agent` on PATH (spawn fails with ENOENT → 501). + return "agent"; +} + +export function isRasterImageBuffer(buf: Buffer): boolean { + if (buf.length >= 8 && buf.subarray(0, 8).equals(PNG_MAGIC)) return true; + if (buf.length >= 3 && buf.subarray(0, 3).equals(JPEG_MAGIC)) return true; + return false; +} + +export async function findCursorAgentImageOutput( + workspace: string, + preferredPath: string +): Promise { + if (existsSync(preferredPath)) return preferredPath; + try { + const entries = await readdir(workspace); + const match = entries.find((name) => /\.(png|jpe?g|webp)$/i.test(name)); + return match ? join(workspace, match) : null; + } catch { + return null; + } +} + +function normalizePositiveInt(value: unknown, fallback: number, max?: number): number { + const n = Number(value); + if (!Number.isFinite(n) || n <= 0) return fallback; + const i = Math.floor(n); + return typeof max === "number" ? Math.min(i, max) : i; +} + +/** + * Effective per-image wall clock: a caller-supplied `timeout_ms` clamped to + * MAX_TIMEOUT_MS. When the request omits it, fall back to the operator default + * (CURSOR_IMG_TIMEOUT_MS) / DEFAULT_TIMEOUT_MS uncapped — operator config is + * trusted; only the untrusted request value is clamped. + */ +export function resolveCursorImageTimeoutMs(rawTimeout: unknown): number { + return normalizePositiveInt( + rawTimeout, + normalizePositiveInt(process.env.CURSOR_IMG_TIMEOUT_MS, DEFAULT_TIMEOUT_MS), + MAX_TIMEOUT_MS + ); +} + +type CursorAgentImageCredentials = { + apiKey?: string; + accessToken?: string; + providerSpecificData?: Record | null; +}; + +function extractSeatToken(credentials: CursorAgentImageCredentials): string { + const raw = credentials?.accessToken || credentials?.apiKey || ""; + return typeof raw === "string" ? raw.trim() : ""; +} + +function extractAgentBinOverride(credentials: CursorAgentImageCredentials): string | null { + const psd = credentials?.providerSpecificData; + if (!psd || typeof psd !== "object" || Array.isArray(psd)) return null; + const bin = psd.agentBin; + return typeof bin === "string" && bin.trim() ? bin.trim() : null; +} + +function extractAgentModel(credentials: CursorAgentImageCredentials, requestModel: string): string { + const psd = credentials?.providerSpecificData; + if (psd && typeof psd === "object" && !Array.isArray(psd)) { + const fromPsd = psd.imageModel; + if (typeof fromPsd === "string" && fromPsd.trim()) return fromPsd.trim(); + } + if (process.env.CURSOR_IMG_MODEL?.trim()) return process.env.CURSOR_IMG_MODEL.trim(); + // The request's `model=cursor/<…>` field is untrusted and flows into the CLI + // `--model` argv — clamp it to the registry allowlist (unknown/flag-shaped → + // "auto"). The operator overrides above (connection psd / CURSOR_IMG_MODEL) + // are trusted deployment config and pass through unchanged. + return resolveCursorImageModel( + requestModel && requestModel !== "cursor" ? requestModel : DEFAULT_MODEL + ); +} + +// ─── process-wide concurrency gate (one shared Cursor seat) ───────────────── + +type Waiter = () => void; +let activeGenerations = 0; +const waitQueue: Waiter[] = []; + +export function __resetCursorAgentImageConcurrencyForTests(): void { + activeGenerations = 0; + waitQueue.length = 0; +} + +function maxConcurrent(): number { + return normalizePositiveInt(process.env.CURSOR_IMG_MAX_CONCURRENT, DEFAULT_MAX_CONCURRENT); +} + +async function acquireSlot(): Promise { + if (activeGenerations < maxConcurrent()) { + activeGenerations += 1; + return; + } + await new Promise((resolve) => { + waitQueue.push(() => { + activeGenerations += 1; + resolve(); + }); + }); +} + +function releaseSlot(): void { + activeGenerations = Math.max(0, activeGenerations - 1); + const next = waitQueue.shift(); + if (next) next(); +} + +export type RunCursorAgentImageOptions = { + agentBin: string; + workspace: string; + prompt: string; + model: string; + authEnv: Record; + timeoutMs: number; + spawnImpl?: typeof spawn; +}; + +/** Spawn `agent -p --force …` and resolve when it exits 0 (or reject on timeout/error). */ +export function runCursorAgentImageProcess(opts: RunCursorAgentImageOptions): Promise<{ + stdout: string; + stderr: string; +}> { + const spawnImpl = opts.spawnImpl ?? spawn; + const args = [ + "-p", + "--force", + "--model", + opts.model, + "--workspace", + opts.workspace, + "--output-format", + "text", + opts.prompt, + ]; + + return new Promise((resolve, reject) => { + const child = spawnImpl(opts.agentBin, args, { + cwd: opts.workspace, + env: { + ...process.env, + ...opts.authEnv, + HOME: process.env.HOME || homedir(), + }, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + const timer = setTimeout(() => { + child.kill("SIGKILL"); + reject(new Error(`Cursor Agent image generation timed out after ${opts.timeoutMs}ms`)); + }, opts.timeoutMs); + + child.stdout?.on("data", (chunk: Buffer | string) => { + stdout += String(chunk); + }); + child.stderr?.on("data", (chunk: Buffer | string) => { + stderr += String(chunk); + }); + child.on("error", (err) => { + clearTimeout(timer); + reject(err); + }); + child.on("close", (code) => { + clearTimeout(timer); + if (code === 0) { + resolve({ stdout, stderr }); + return; + } + reject( + new Error( + `Cursor Agent exited ${code}: ${(stderr || stdout).trim().slice(0, 400) || "no output"}` + ) + ); + }); + }); +} + +async function generateOneImage(params: { + userPrompt: string; + size: unknown; + agentBin: string; + model: string; + authEnv: Record; + timeoutMs: number; + spawnImpl?: typeof spawn; +}): Promise { + const workspace = await mkdtemp(join(tmpdir(), "omni-cursor-img-")); + const outPath = join(workspace, "out.png"); + const prompt = buildCursorAgentImagePrompt(params.userPrompt, outPath, params.size); + + try { + await runCursorAgentImageProcess({ + agentBin: params.agentBin, + workspace, + prompt, + model: params.model, + authEnv: params.authEnv, + timeoutMs: params.timeoutMs, + spawnImpl: params.spawnImpl, + }); + + const found = await findCursorAgentImageOutput(workspace, outPath); + if (!found) { + throw new Error("Cursor Agent produced no image file in the workspace"); + } + const buf = await readFile(found); + if (!isRasterImageBuffer(buf)) { + throw new Error("Cursor Agent output is not a PNG/JPEG raster"); + } + return buf; + } finally { + await rm(workspace, { recursive: true, force: true }).catch(() => {}); + } +} + +export async function handleCursorAgentImageGeneration({ + model, + provider, + providerConfig: _providerConfig, + body, + credentials, + log, + spawnImpl, + peerLocality, +}: { + model: string; + provider: string; + providerConfig: { baseUrl?: string }; + body: { + prompt?: unknown; + size?: unknown; + n?: unknown; + timeout_ms?: unknown; + }; + credentials: CursorAgentImageCredentials; + log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; + /** Test seam — defaults to node:child_process.spawn */ + spawnImpl?: typeof spawn; + /** + * Trusted locality verdict ("loopback" | "lan" | "remote") forwarded by the + * route layer from `AUTHZ_HEADER_PEER_LOCALITY` (stamped by the authz + * pipeline from the real TCP peer, never the spoofable Host header). Absent + * or unrecognized → fail closed (treated as "remote"). + */ + peerLocality?: string | null; +}) { + const startTime = Date.now(); + + // Hard Rules #15 + #17: reject before doing ANY other work — credential + // lookup, prompt validation, and the `agent` binary spawn itself must never + // run for a non-loopback/non-LAN caller. A leaked API key tunneled from the + // public internet must not be able to trigger a child-process spawn on the + // OmniRoute host. + if (!peerLocality || !SPAWN_ALLOWED_LOCALITIES.has(peerLocality)) { + return saveImageErrorResult({ + provider, + model, + status: 403, + startTime, + error: + "Cursor Agent image generation spawns a local process and is only available from localhost or the private LAN OmniRoute runs on.", + }); + } + + const prompt = typeof body.prompt === "string" ? body.prompt.trim() : ""; + if (!prompt) { + return saveImageErrorResult({ + provider, + model, + status: 400, + startTime, + error: "Prompt is required for Cursor Agent image generation", + }); + } + + const token = extractSeatToken(credentials); + if (!token) { + return saveImageErrorResult({ + provider, + model, + status: 401, + startTime, + error: "Cursor credentials missing accessToken — reconnect the Cursor provider", + }); + } + + const agentBin = resolveCursorAgentBin(extractAgentBinOverride(credentials)); + if (!agentBin || (agentBin !== "agent" && !existsSync(agentBin))) { + // Bare "agent" may still resolve via PATH; only hard-fail when an explicit path is missing. + if (agentBin !== "agent") { + return saveImageErrorResult({ + provider, + model, + status: 501, + startTime, + error: + "Cursor Agent CLI not found. Install the Cursor `agent` binary and set CURSOR_AGENT_BIN, or set providerSpecificData.agentBin on the Cursor connection.", + }); + } + } + + const timeoutMs = resolveCursorImageTimeoutMs(body.timeout_ms); + const count = normalizePositiveInt(body.n, 1, MAX_N); + const agentModel = extractAgentModel(credentials, model); + const authEnv = buildCursorAgentAuthEnv(token); + + if (log?.info) { + log.info( + "IMAGE", + `${provider}/${model} (cursor-agent-image) | n=${count} model=${agentModel} bin=${agentBin}` + ); + } + + const images: Array<{ b64_json: string; revised_prompt: string }> = []; + + try { + for (let i = 0; i < count; i++) { + await acquireSlot(); + try { + const buf = await generateOneImage({ + userPrompt: prompt, + size: body.size, + agentBin: agentBin || "agent", + model: agentModel, + authEnv, + timeoutMs, + spawnImpl, + }); + images.push({ b64_json: buf.toString("base64"), revised_prompt: prompt }); + } finally { + releaseSlot(); + } + } + + return saveImageSuccessResult({ + provider, + model, + startTime, + images, + }); + } catch (err) { + const errorText = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + if (log?.error) { + log.error("IMAGE", `${provider} cursor-agent-image error: ${errorText}`); + } + // ENOENT from spawn → treat as missing CLI + const status = + err && typeof err === "object" && "code" in err && (err as { code?: string }).code === "ENOENT" + ? 501 + : 502; + return saveImageErrorResult({ + provider, + model, + status, + startTime, + error: + status === 501 + ? "Cursor Agent CLI not found on PATH. Set CURSOR_AGENT_BIN to the `agent` binary." + : errorText, + }); + } +} diff --git a/src/app/api/v1/images/generations/route.ts b/src/app/api/v1/images/generations/route.ts index 41916a55e6..aa228a4f75 100644 --- a/src/app/api/v1/images/generations/route.ts +++ b/src/app/api/v1/images/generations/route.ts @@ -31,6 +31,7 @@ import { getSpecialtyModelsResponse } from "@/app/api/v1/_shared/specialtyCatalo import { enforceClientApiRouteAuth } from "@/shared/utils/clientApiRouteAuth"; import { runWithCallLogApiKeyContext } from "@/lib/usage/callLogApiKeyContext"; import { executeImageWithCredentialFallback } from "@/sse/services/imageCredentialRetry"; +import { AUTHZ_HEADER_PEER_LOCALITY } from "@/server/authz/headers"; export const dynamic = "force-dynamic"; @@ -290,6 +291,12 @@ async function postHandler(request, context) { ...(isCustomModel && { resolvedProvider: provider }), signal: request.signal, clientHeaders: publicBaseUrlHeaders(request.headers), + // Trusted "loopback"|"lan"|"remote" verdict stamped by the authz + // pipeline from the real TCP peer (never the spoofable Host + // header). Only the spawn-capable cursor-agent-image provider + // consumes this (Hard Rules #15 + #17) — every other image + // provider ignores it. + peerLocality: request.headers.get(AUTHZ_HEADER_PEER_LOCALITY), }) ); diff --git a/tests/unit/cursor-agent-image.test.ts b/tests/unit/cursor-agent-image.test.ts new file mode 100644 index 0000000000..e4c2cfea2d --- /dev/null +++ b/tests/unit/cursor-agent-image.test.ts @@ -0,0 +1,297 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { IMAGE_PROVIDERS, parseImageModel, getImageProvider } from "../../open-sse/config/imageRegistry.ts"; +import { + buildCursorAgentAuthEnv, + buildCursorAgentImagePrompt, + CURSOR_AGENT_IMAGE_FORMAT, + handleCursorAgentImageGeneration, + isRasterImageBuffer, + normalizeCursorSeatToken, + resolveCursorImageModel, + resolveCursorImageTimeoutMs, + __resetCursorAgentImageConcurrencyForTests, +} from "../../open-sse/handlers/imageGeneration/providers/cursorAgentImage.ts"; + +test("cursor is registered in IMAGE_PROVIDERS with cursor-agent-image format", () => { + const entry = IMAGE_PROVIDERS.cursor; + assert.ok(entry, "expected IMAGE_PROVIDERS.cursor"); + assert.equal(entry.id, "cursor"); + assert.equal(entry.alias, "cu"); + assert.equal(entry.format, CURSOR_AGENT_IMAGE_FORMAT); + assert.equal(entry.authType, "oauth"); + assert.equal(entry.authHeader, "bearer"); + assert.ok(entry.models.some((m) => m.id === "auto")); + assert.deepEqual(getImageProvider("cursor"), entry); +}); + +test("parseImageModel resolves cursor/auto and cu/auto to the cursor image provider", () => { + assert.deepEqual(parseImageModel("cursor/auto"), { provider: "cursor", model: "auto" }); + assert.deepEqual(parseImageModel("cu/auto"), { provider: "cursor", model: "auto" }); +}); + +test("normalizeCursorSeatToken strips account:: prefix like CursorExecutor", () => { + assert.equal(normalizeCursorSeatToken("acct::tok_abc"), "tok_abc"); + assert.equal(normalizeCursorSeatToken(" crsr_live "), "crsr_live"); + assert.equal(normalizeCursorSeatToken("a::b::c"), "b::c"); +}); + +test("buildCursorAgentAuthEnv maps crsr_ to CURSOR_API_KEY and JWTs to CURSOR_AUTH_TOKEN", () => { + assert.deepEqual(buildCursorAgentAuthEnv("crsr_abc"), { CURSOR_API_KEY: "crsr_abc" }); + assert.deepEqual(buildCursorAgentAuthEnv("user::crsr_abc"), { CURSOR_API_KEY: "crsr_abc" }); + assert.deepEqual(buildCursorAgentAuthEnv("eyJhbGciOi.jwt"), { + CURSOR_AUTH_TOKEN: "eyJhbGciOi.jwt", + }); +}); + +test("buildCursorAgentImagePrompt locks the agent to native generateImage + exact out path", () => { + const prompt = buildCursorAgentImagePrompt("a red cube", "/tmp/out.png", "1024x1024"); + assert.match(prompt, /native image-generation tool/i); + assert.match(prompt, /Do NOT write code/); + assert.match(prompt, /a red cube/); + assert.match(prompt, /1024x1024/); + assert.match(prompt, /\/tmp\/out\.png/); + assert.match(prompt, /\bDONE\b/); +}); + +test("isRasterImageBuffer accepts PNG and JPEG magics", () => { + const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00]); + const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0]); + assert.equal(isRasterImageBuffer(png), true); + assert.equal(isRasterImageBuffer(jpeg), true); + assert.equal(isRasterImageBuffer(Buffer.from("not-an-image")), false); +}); + +test("handleCursorAgentImageGeneration rejects empty prompt and missing credentials", async () => { + __resetCursorAgentImageConcurrencyForTests(); + const noPrompt = await handleCursorAgentImageGeneration({ + model: "auto", + provider: "cursor", + providerConfig: { baseUrl: "agent://cursor-agent" }, + body: { prompt: " " }, + credentials: { accessToken: "crsr_x" }, + peerLocality: "loopback", + }); + assert.equal(noPrompt.success, false); + assert.equal(noPrompt.status, 400); + + const noCreds = await handleCursorAgentImageGeneration({ + model: "auto", + provider: "cursor", + providerConfig: { baseUrl: "agent://cursor-agent" }, + body: { prompt: "hi" }, + credentials: {}, + peerLocality: "loopback", + }); + assert.equal(noCreds.success, false); + assert.equal(noCreds.status, 401); +}); + +test("handleCursorAgentImageGeneration returns 501 when agentBin path is missing", async () => { + __resetCursorAgentImageConcurrencyForTests(); + const result = await handleCursorAgentImageGeneration({ + model: "auto", + provider: "cursor", + providerConfig: { baseUrl: "agent://cursor-agent" }, + body: { prompt: "a lantern" }, + credentials: { + accessToken: "crsr_test", + providerSpecificData: { agentBin: "/nonexistent/cursor-agent-bin" }, + }, + peerLocality: "loopback", + }); + assert.equal(result.success, false); + assert.equal(result.status, 501); + assert.match(String(result.error), /CURSOR_AGENT_BIN|agentBin/i); +}); + +// ─── Hard Rules #15 + #17: spawn-capable providers must loopback/LAN-gate ─── + +test("handleCursorAgentImageGeneration rejects a non-loopback/non-LAN caller BEFORE spawning", async () => { + __resetCursorAgentImageConcurrencyForTests(); + let spawnCalled = false; + const spyingSpawn = (() => { + spawnCalled = true; + throw new Error("spawn must never be invoked for a remote caller"); + }) as unknown as typeof import("node:child_process").spawn; + + const result = await handleCursorAgentImageGeneration({ + model: "auto", + provider: "cursor", + providerConfig: { baseUrl: "agent://cursor-agent" }, + body: { prompt: "a lantern in fog" }, + credentials: { + accessToken: "crsr_test", + providerSpecificData: { agentBin: process.execPath }, + }, + spawnImpl: spyingSpawn, + peerLocality: "remote", + }); + + assert.equal(spawnCalled, false, "spawn must not run for a rejected non-local caller"); + assert.equal(result.success, false); + assert.equal(result.status, 403); + assert.match(String(result.error), /localhost|LAN/i); +}); + +test("handleCursorAgentImageGeneration rejects when peerLocality is missing (fail closed)", async () => { + __resetCursorAgentImageConcurrencyForTests(); + const result = await handleCursorAgentImageGeneration({ + model: "auto", + provider: "cursor", + providerConfig: { baseUrl: "agent://cursor-agent" }, + body: { prompt: "a lantern in fog" }, + credentials: { accessToken: "crsr_test" }, + }); + assert.equal(result.success, false); + assert.equal(result.status, 403); +}); + +/** + * Minimal fake `spawn` that writes a tiny PNG to the out path embedded in the + * prompt and exits 0 — exercises the success path without a real Cursor Agent. + */ +test("handleCursorAgentImageGeneration returns b64_json via injectable spawn", async () => { + __resetCursorAgentImageConcurrencyForTests(); + const { writeFile, mkdir } = await import("node:fs/promises"); + const path = await import("node:path"); + + const tinyPng = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + ]); + + const fakeSpawn = ((bin: string, args: string[]) => { + assert.ok(bin, "agent bin required"); + const prompt = args[args.length - 1] || ""; + const marker = "Save the resulting image to exactly this path: "; + const idx = prompt.indexOf(marker); + assert.ok(idx >= 0, "prompt must contain out path"); + const after = prompt.slice(idx + marker.length); + const end = after.indexOf(". When the file exists"); + assert.ok(end > 0, "prompt must end out path before DONE clause"); + const outPath = after.slice(0, end); + const child = new EventEmitter() as EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + kill: () => void; + }; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.kill = () => undefined; + queueMicrotask(async () => { + await mkdir(path.dirname(outPath), { recursive: true }); + await writeFile(outPath, tinyPng); + child.emit("close", 0); + }); + return child; + }) as unknown as typeof import("node:child_process").spawn; + + // Use an existing path so the preflight existsSync check passes; spawn is faked. + const result = await handleCursorAgentImageGeneration({ + model: "auto", + provider: "cursor", + providerConfig: { baseUrl: "agent://cursor-agent" }, + body: { prompt: "a lantern in fog", size: "1024x1024", n: 1 }, + credentials: { + accessToken: "crsr_test", + providerSpecificData: { agentBin: process.execPath }, + }, + spawnImpl: fakeSpawn, + peerLocality: "loopback", + }); + + assert.equal(result.success, true); + assert.ok(result.data?.data?.[0]?.b64_json); + assert.equal(result.data.data[0].b64_json, tinyPng.toString("base64")); +}); + +test("resolveCursorImageModel allows only registry models, clamping everything else to auto", () => { + // The three ids declared in IMAGE_PROVIDERS.cursor.models pass through verbatim. + for (const m of IMAGE_PROVIDERS.cursor.models) { + assert.equal(resolveCursorImageModel(m.id), m.id); + } + // Unknown models and (crucially) flag-shaped / injection-y strings fall back to auto. + assert.equal(resolveCursorImageModel("--dangerously-allow-shell"), "auto"); + assert.equal(resolveCursorImageModel("-p"), "auto"); + assert.equal(resolveCursorImageModel("composer-9"), "auto"); + assert.equal(resolveCursorImageModel(" composer-2 "), "composer-2"); // trimmed, still valid + assert.equal(resolveCursorImageModel(""), "auto"); + assert.equal(resolveCursorImageModel(undefined), "auto"); + assert.equal(resolveCursorImageModel(42), "auto"); +}); + +/** + * End-to-end guard: an odd/flag-shaped `model` from the request must never reach + * the spawned Agent CLI argv — the handler resolves it to "auto" first. + */ +test("handleCursorAgentImageGeneration never forwards a flag-shaped model into CLI argv", async () => { + __resetCursorAgentImageConcurrencyForTests(); + const { writeFile, mkdir } = await import("node:fs/promises"); + const path = await import("node:path"); + + const tinyPng = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + ]); + + let capturedArgs: string[] = []; + const fakeSpawn = ((_bin: string, args: string[]) => { + capturedArgs = args; + const prompt = args[args.length - 1] || ""; + const marker = "Save the resulting image to exactly this path: "; + const idx = prompt.indexOf(marker); + const after = prompt.slice(idx + marker.length); + const outPath = after.slice(0, after.indexOf(". When the file exists")); + const child = new EventEmitter() as EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + kill: () => void; + }; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.kill = () => undefined; + queueMicrotask(async () => { + await mkdir(path.dirname(outPath), { recursive: true }); + await writeFile(outPath, tinyPng); + child.emit("close", 0); + }); + return child; + }) as unknown as typeof import("node:child_process").spawn; + + const result = await handleCursorAgentImageGeneration({ + model: "--dangerously-allow-shell", // untrusted, flag-shaped + provider: "cursor", + providerConfig: { baseUrl: "agent://cursor-agent" }, + body: { prompt: "a lantern in fog", n: 1 }, + credentials: { + accessToken: "crsr_test", + providerSpecificData: { agentBin: process.execPath }, + }, + spawnImpl: fakeSpawn, + peerLocality: "loopback", + }); + + assert.equal(result.success, true); + const modelIdx = capturedArgs.indexOf("--model"); + assert.ok(modelIdx >= 0, "expected --model in the CLI argv"); + assert.equal(capturedArgs[modelIdx + 1], "auto", "flag-shaped model must resolve to auto"); + assert.ok( + !capturedArgs.includes("--dangerously-allow-shell"), + "the raw flag-shaped model must not appear anywhere in argv" + ); +}); + +test("resolveCursorImageTimeoutMs clamps caller timeout_ms to the 300s ceiling", () => { + const prev = process.env.CURSOR_IMG_TIMEOUT_MS; + delete process.env.CURSOR_IMG_TIMEOUT_MS; // isolate from any operator default + try { + assert.equal(resolveCursorImageTimeoutMs(5_000), 5_000); // under the cap: unchanged + assert.equal(resolveCursorImageTimeoutMs(300_000), 300_000); // exactly at the cap + assert.equal(resolveCursorImageTimeoutMs(999_999_999), 300_000); // over the cap: clamped + assert.equal(resolveCursorImageTimeoutMs(-1), 210_000); // invalid → default fallback + assert.equal(resolveCursorImageTimeoutMs(undefined), 210_000); // absent → default fallback + } finally { + if (prev === undefined) delete process.env.CURSOR_IMG_TIMEOUT_MS; + else process.env.CURSOR_IMG_TIMEOUT_MS = prev; + } +});