mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-20 22:22:57 +03:00
Compare commits
5 Commits
release/v3
...
feat/8338-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d3a5f9243 | ||
|
|
9df2e68fb6 | ||
|
|
31653352cb | ||
|
|
b5ef904b94 | ||
|
|
d1907fd085 |
1
changelog.d/features/cursor-agent-image-provider.md
Normal file
1
changelog.d/features/cursor-agent-image-provider.md
Normal 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
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
75
docs/providers/CURSOR_IMAGE.md
Normal file
75
docs/providers/CURSOR_IMAGE.md
Normal 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 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).
|
||||
@@ -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 | — | — |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
488
open-sse/handlers/imageGeneration/providers/cursorAgentImage.ts
Normal file
488
open-sse/handlers/imageGeneration/providers/cursorAgentImage.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
297
tests/unit/cursor-agent-image.test.ts
Normal file
297
tests/unit/cursor-agent-image.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user