Compare commits

...

5 Commits

Author SHA1 Message Date
Markus Hartung
3d3a5f9243 chore(quality): rebaseline imageRegistry.ts for cursor image provider
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-20 09:02:49 -03:00
Markus Hartung
9df2e68fb6 test(cursor-image): thread peerLocality through the flag-shaped-model test
The model-allowlist test (from the allowlist/timeout-clamp commit) and the
loopback/LAN guard (from the security-guard commit) were written independently
against diverging histories of this PR and didn't know about each other —
cherry-picking both here exposed the gap: this one test still called
handleCursorAgentImageGeneration without peerLocality, so the guard's
fail-closed default rejected it with 403 before reaching the argv-sanitization
logic the test actually exercises.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-20 09:00:38 -03:00
valvesss
31653352cb fix(sse): loopback/LAN-gate the cursor-agent-image spawn path (Hard Rules #15/#17)
handleCursorAgentImageGeneration spawns the Cursor `agent` CLI, but
POST /v1/images/generations is shared by ~40 non-spawning image providers
that remote callers legitimately use, so the whole route can't be
classified LOCAL_ONLY without breaking them. Instead the handler now
rejects before any credential lookup or spawn unless the trusted
AUTHZ_HEADER_PEER_LOCALITY verdict (stamped by the authz pipeline from
the real TCP peer, never the spoofable Host header) is "loopback" or
"lan" — mirroring the policy every other LOCAL_ONLY route already gets.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-20 08:58:25 -03:00
valvesss
b5ef904b94 fix(cursor-image): allowlist CLI model + clamp request timeout (PR #8338 review)
Two mechanical hardening fixes requested in review:

- Model: the request's `model=cursor/<…>` flowed unfiltered into the Agent CLI
  `--model` argv. Add resolveCursorImageModel() — an allowlist derived from
  IMAGE_PROVIDERS.cursor.models (auto | composer-2 | composer-2.5), mirroring the
  auggie executor. Unknown or flag-shaped values fall back to "auto" and never
  reach argv. Operator overrides (connection psd / CURSOR_IMG_MODEL) stay trusted.

- Timeout: request `timeout_ms` had no upper bound while the shared Cursor seat
  has only CURSOR_IMG_MAX_CONCURRENT (default 2) slots. Add
  resolveCursorImageTimeoutMs() clamping the caller value to MAX_TIMEOUT_MS
  (300000ms); the operator default (CURSOR_IMG_TIMEOUT_MS) stays uncapped.

Tests: +3 (12/12) — allowlist unit test, end-to-end proof a flag-shaped model
resolves to "auto" in argv, and the timeout clamp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-20 08:57:50 -03:00
valvesss
d1907fd085 feat(sse): Cursor plan images via Agent CLI (IMAGE_PROVIDERS.cursor)
Add cursor-agent-image format that reuses the chat Cursor OAuth connection and
drives the Agent CLI native generateImage tool with a locked prompt and
per-request workspace. Chat AgentService rejects built-ins; CLI is the seat path.
2026-08-20 08:57:47 -03:00
10 changed files with 914 additions and 4 deletions

View File

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

View File

@@ -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."
}

View File

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

View File

@@ -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://<host>/v1/images/generations \
-H "Authorization: Bearer <omni-api-key>" \
-H "Content-Type: application/json" \
-d '{"model":"cursor/auto","prompt":"a lantern in fog","size":"1024x1024"}'
```
Generation typically takes 12 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).

View File

@@ -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 | — | — |

View File

@@ -267,6 +267,25 @@ export const IMAGE_PROVIDERS: Record<string, ImageProviderConfig> = {
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",

View File

@@ -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";
@@ -277,6 +278,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,
@@ -285,6 +290,7 @@ export async function handleImageGeneration({
resolvedProvider = null,
signal = null,
clientHeaders = null,
peerLocality = null,
}) {
let provider, model;
@@ -495,6 +501,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,

View File

@@ -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<string> = 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<string, string> {
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<string | null> {
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<string, unknown> | 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<void> {
if (activeGenerations < maxConcurrent()) {
activeGenerations += 1;
return;
}
await new Promise<void>((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<string, string>;
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<string, string>;
timeoutMs: number;
spawnImpl?: typeof spawn;
}): Promise<Buffer> {
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,
});
}
}

View File

@@ -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),
})
);

View File

@@ -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;
}
});