diff --git a/changelog.d/fixes/0-cursor-sync-locality.md b/changelog.d/fixes/0-cursor-sync-locality.md new file mode 100644 index 000000000000..bcfb1e04db17 --- /dev/null +++ b/changelog.d/fixes/0-cursor-sync-locality.md @@ -0,0 +1 @@ +- **fix(providers):** Preserve the original caller's local CLI restriction during Cursor model sync and carry trusted locality into the in-process fallback. diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index fc1bdfd11def..557eeedea416 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -109,6 +109,7 @@ import { getAdobeModels } from "./adobeFireflyDiscovery"; import { getSyncedAvailableModels, getCustomModels, getModelIsHidden } from "@/lib/db/models"; import { isConnectionUnavailableToAuxiliaryActivity } from "@/lib/exclusiveLeaseIsolation"; import { AUTHZ_HEADER_PEER_LOCALITY } from "@/server/authz/headers"; +import { isModelSyncLocalSpawnDenied } from "@/shared/services/modelSyncScheduler"; import { fetchCursorAgentModels } from "@/lib/providerModels/cursorAgent"; import { fetchCursorAvailableModels } from "@/lib/providerModels/cursorAvailableModels"; import { ensureCursorAutoCatalogEntry } from "@/lib/providerModels/cursorAutoCatalog"; @@ -1510,14 +1511,17 @@ export async function GET( // TCP peer (never the spoofable Host header), mirroring cursorAgentImage.ts. Fail closed: // an absent/unrecognized locality skips the spawn and serves the cached/local catalog, or an // explicit 403 when neither exists — it never falls through to executing a child process. - if (request.headers.get(AUTHZ_HEADER_PEER_LOCALITY) !== "loopback") { + if ( + request.headers.get(AUTHZ_HEADER_PEER_LOCALITY) !== "loopback" || + isModelSyncLocalSpawnDenied(request) + ) { warnings.push( "cursor-agent model discovery requires a local request; using cached catalog" ); const localFallback = buildDiscoveryFallbackResponse({ cacheWarning: `${warnings.join("; ")} — using cached catalog`, localWarning: `${warnings.join("; ")} — using local catalog`, - // The discovery needs a local request that this request is not. + // Local CLI discovery is not permitted for this request. failure: { code: "LOCALITY_REFUSED" }, }); if (localFallback) return localFallback; diff --git a/src/app/api/providers/[id]/sync-models/route.ts b/src/app/api/providers/[id]/sync-models/route.ts index 4de3438dcd8c..b08d90aac086 100644 --- a/src/app/api/providers/[id]/sync-models/route.ts +++ b/src/app/api/providers/[id]/sync-models/route.ts @@ -15,11 +15,13 @@ import { import { createModelSyncTimings } from "@/lib/providerModels/modelSyncTimings"; import { saveCallLog } from "@/lib/usage/callLogs"; import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { AUTHZ_HEADER_PEER_LOCALITY } from "@/server/authz/headers"; import { buildModelSyncInternalHeaders, fetchModelSyncInternal, getModelSyncInternalBaseUrl, isModelSyncInternalRequest, + isModelSyncLocalSpawnDenied, } from "@/shared/services/modelSyncScheduler"; import { autoSyncCodexProfilesFromLiveCatalog } from "@/lib/cli-helper/codexProfileAutoSync"; import { autoSyncClaudeProfilesFromLiveCatalog } from "@/lib/cli-helper/claudeProfileAutoSync"; @@ -372,10 +374,12 @@ async function fetchProviderModelsForSync(request: Request, connectionId: string const modelsPath = `/api/providers/${encodeURIComponent(connectionId)}/models` + "?refresh=true&excludeCustom=true"; + const peerLocality = request.headers.get(AUTHZ_HEADER_PEER_LOCALITY) || "remote"; + const denyLocalSpawn = peerLocality !== "loopback" || isModelSyncLocalSpawnDenied(request); const headers = { cookie: request.headers.get("cookie") || "", "x-omniroute-model-surface": "chat", - ...buildModelSyncInternalHeaders(), + ...buildModelSyncInternalHeaders({ denyLocalSpawn }), }; const targetUrl = `${safeOrigin}${modelsPath}`; @@ -391,7 +395,8 @@ async function fetchProviderModelsForSync(request: Request, connectionId: string getProviderModels( new Request(new URL(modelsPath, "http://localhost").href, { method: "GET", - headers, + // This direct call bypasses the pipeline that normally stamps trusted locality. + headers: { ...headers, [AUTHZ_HEADER_PEER_LOCALITY]: peerLocality }, }), { params: { id: connectionId } } ), diff --git a/src/shared/services/modelSyncScheduler.ts b/src/shared/services/modelSyncScheduler.ts index 9363f2abd7ea..ae0eec0e133e 100644 --- a/src/shared/services/modelSyncScheduler.ts +++ b/src/shared/services/modelSyncScheduler.ts @@ -31,6 +31,7 @@ export const MODEL_SYNC_STARTUP_DELAY_MS = 90_000; export const MODEL_SYNC_STAGGER_OFFSET_MS = 45 * 60 * 1000; // 45 minutes const MODEL_SYNC_SETTING_KEY = "model_sync_last_run"; const MODEL_SYNC_INTERNAL_AUTH_HEADER = "x-model-sync-internal-auth"; +export const MODEL_SYNC_LOCAL_SPAWN_DENIED_HEADER = "x-model-sync-local-spawn-denied"; function normalizeInternalBasePath(value: string | undefined): string { const trimmed = value?.trim(); @@ -148,8 +149,13 @@ export function getModelSyncInternalAuthHeaderName(): string { return MODEL_SYNC_INTERNAL_AUTH_HEADER; } -export function buildModelSyncInternalHeaders(): Record { - return { [MODEL_SYNC_INTERNAL_AUTH_HEADER]: getInternalAuthToken() }; +export function buildModelSyncInternalHeaders( + options: { denyLocalSpawn?: boolean } = {} +): Record { + return { + [MODEL_SYNC_INTERNAL_AUTH_HEADER]: getInternalAuthToken(), + ...(options.denyLocalSpawn ? { [MODEL_SYNC_LOCAL_SPAWN_DENIED_HEADER]: "1" } : {}), + }; } export function isModelSyncInternalRequest(request: { headers: Headers }): boolean { @@ -162,6 +168,13 @@ export function isModelSyncInternalRequest(request: { headers: Headers }): boole ); } +export function isModelSyncLocalSpawnDenied(request: { headers: Headers }): boolean { + return ( + isModelSyncInternalRequest(request) && + request.headers.get(MODEL_SYNC_LOCAL_SPAWN_DENIED_HEADER) === "1" + ); +} + /** Providers whose connections are created with `autoSync: true` (#488). */ const AUTO_SYNC_DEFAULT_PROVIDERS = new Set(["antigravity", "agy"]); diff --git a/tests/unit/api/cursor-model-sync-denial.spec.ts b/tests/unit/api/cursor-model-sync-denial.spec.ts new file mode 100644 index 000000000000..554c5da837b4 --- /dev/null +++ b/tests/unit/api/cursor-model-sync-denial.spec.ts @@ -0,0 +1,288 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { setImmediate as yieldIo } from "node:timers/promises"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; + +const boundaries = vi.hoisted(() => ({ + cli: vi.fn(async () => [{ id: "gpt-5.3-codex", name: "Codex 5.3", owned_by: "cursor" }]), + provider: vi.fn(), +})); +vi.mock("@/lib/providerModels/cursorAgent", async (importOriginal) => ({ + ...(await importOriginal()), + fetchCursorAgentModels: boundaries.cli, +})); +vi.mock("@/shared/network/safeOutboundFetch", async (importOriginal) => ({ + ...(await importOriginal()), + safeOutboundFetch: boundaries.provider, +})); +vi.mock("@/lib/usage/callLogs", () => ({ saveCallLog: vi.fn(async () => undefined) })); +vi.mock("@/lib/cli-helper/codexProfileAutoSync", () => ({ + autoSyncCodexProfilesFromLiveCatalog: vi.fn(async () => ({ ok: false, reason: "fixture" })), +})); +vi.mock("@/lib/cli-helper/claudeProfileAutoSync", () => ({ + autoSyncClaudeProfilesFromLiveCatalog: vi.fn(async () => ({ ok: false, reason: "fixture" })), +})); + +const dataDir = mkdtempSync(join(tmpdir(), "cursor-sync-denial-")); +const originalEnv = { + DATA_DIR: process.env.DATA_DIR, + INITIAL_PASSWORD: process.env.INITIAL_PASSWORD, + OMNIROUTE_INTERNAL_SCHEME: process.env.OMNIROUTE_INTERNAL_SCHEME, + OMNIROUTE_BASE_PATH: process.env.OMNIROUTE_BASE_PATH, +}; +process.env.DATA_DIR = dataDir; +process.env.INITIAL_PASSWORD = "isolated-cursor-denial-password"; +delete process.env.OMNIROUTE_INTERNAL_SCHEME; +delete process.env.OMNIROUTE_BASE_PATH; + +const core = await import("@/lib/db/core"); +const providers = await import("@/lib/db/providers"); +const models = await import("@/lib/db/models"); +const settings = await import("@/lib/db/settings"); +const modelSync = await import("@/shared/services/modelSyncScheduler"); +const syncRoute = await import("@/app/api/providers/[id]/sync-models/route"); +const modelsRoute = await import("@/app/api/providers/[id]/models/route"); +const { managementPolicy } = await import("@/server/authz/policies/management"); +const { classifyRoute } = await import("@/server/authz/classify"); +const { + AUTHZ_HEADER_PEER_LOCALITY, + AUTHZ_TRUSTED_HEADERS, + PEER_IP_HEADER, + VIA_PROXY_HEADER, + CLIENT_IP_HEADER, +} = await import("@/server/authz/headers"); +const denyHeader = modelSync.MODEL_SYNC_LOCAL_SPAWN_DENIED_HEADER; +const authHeader = modelSync.getModelSyncInternalAuthHeaderName(); +const originalFetch = globalThis.fetch; +type TransportMode = "http" | "readiness-down" | "network-down"; +let transportMode: TransportMode; +let delegatedHeaders: Headers[]; +let onFirstProbe: (() => void) | undefined; + +// The fixture's peer supplies locality after all client-owned trusted-header claims are removed. +function request(path: string, method: string, locality: string, input: HeadersInit): Request { + const headers = new Headers(input); + for (const key of [ + ...AUTHZ_TRUSTED_HEADERS, + PEER_IP_HEADER, + VIA_PROXY_HEADER, + CLIENT_IP_HEADER, + ]) { + headers.delete(key); + } + headers.set(AUTHZ_HEADER_PEER_LOCALITY, locality); + return new Request(`http://localhost${path}`, { method, headers }); +} + +async function connection(withToken = false) { + return providers.createProviderConnection({ + provider: "cursor", + authType: "oauth", + name: "isolated deny-marker fixture", + ...(withToken ? { accessToken: "fixture-cursor-session" } : {}), + }); +} + +function sync(id: string, locality: string, headers = modelSync.buildModelSyncInternalHeaders()) { + return syncRoute.POST( + request(`/api/providers/${id}/sync-models?quiet=1`, "POST", locality, headers), + { params: Promise.resolve({ id }) } + ); +} + +async function throughTransport(mode: TransportMode, operation: () => Promise) { + transportMode = mode; + if (mode === "http") return operation(); + vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); + try { + const firstProbe = new Promise((resolve) => { + onFirstProbe = resolve; + }); + const result = operation(); + await firstProbe; + await vi.advanceTimersByTimeAsync(31_000); + return await result; + } finally { + onFirstProbe = undefined; + vi.useRealTimers(); + } +} + +beforeAll(async () => { + await settings.updateSettings({ requireLogin: true }); +}); + +beforeEach(async () => { + boundaries.cli.mockClear(); + boundaries.provider + .mockReset() + .mockRejectedValue(new Error("fixture HTTP discovery unavailable")); + await models.deleteSyncedAvailableModelsForProvider("cursor"); + syncRoute.__resetLoopbackReadinessForTests(); + transportMode = "http"; + delegatedHeaders = []; + globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(typeof input === "string" || input instanceof URL ? input : input.url); + expect(url.origin).toBe(new URL(modelSync.getModelSyncInternalBaseUrl()).origin); + if (url.pathname.includes("__readiness_probe__")) { + onFirstProbe?.(); + if (transportMode === "readiness-down") throw new TypeError("fixture ECONNREFUSED"); + return new Response(null, { status: 404 }); + } + if (transportMode === "network-down") throw new TypeError("fixture fetch failed"); + const headers = new Headers(init?.headers); + expect(headers.has(AUTHZ_HEADER_PEER_LOCALITY)).toBe(false); + delegatedHeaders.push(headers); + const inner = request(url.pathname + url.search, "GET", "loopback", headers); + expect(modelSync.isModelSyncInternalRequest(inner)).toBe(true); + return modelsRoute.GET(inner, { params: { id: url.pathname.split("/").at(-2) ?? "" } }); + }) as typeof fetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + vi.useRealTimers(); +}); + +afterAll(async () => { + // Drain queued catalog notifications before closing and deleting the fixture database. + await import("@/lib/db/quotaPools"); + await yieldIo(); + core.resetDbInstance(); + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + rmSync(dataDir, { recursive: true, force: true }); +}); + +describe("authenticated deny-only model-sync context", () => { + test("default internal headers preserve the existing scheduler contract", () => { + const headers = modelSync.buildModelSyncInternalHeaders(); + expect(Object.keys(headers)).toEqual([authHeader]); + expect(modelSync.buildModelSyncInternalHeaders({ denyLocalSpawn: false })).toEqual(headers); + expect(modelSync.isModelSyncInternalRequest({ headers: new Headers(headers) })).toBe(true); + expect(modelSync.isModelSyncLocalSpawnDenied({ headers: new Headers(headers) })).toBe(false); + }); + + test("the deny marker is interpreted only with a valid internal token", () => { + const denied = modelSync.buildModelSyncInternalHeaders({ denyLocalSpawn: true }); + expect(new Headers(denied).get(denyHeader)).toBe("1"); + expect(modelSync.isModelSyncLocalSpawnDenied({ headers: new Headers(denied) })).toBe(true); + for (const token of [undefined, "invalid-token"]) { + const headers = new Headers({ [denyHeader]: "1" }); + if (token) headers.set(authHeader, token); + expect(modelSync.isModelSyncLocalSpawnDenied({ headers })).toBe(false); + } + }); + + for (const mode of ["http", "readiness-down", "network-down"] as const) { + test(`a valid inherited denial survives local delegation through ${mode}`, async () => { + const conn = await connection(); + const response = await throughTransport(mode, () => + sync(conn.id, "loopback", modelSync.buildModelSyncInternalHeaders({ denyLocalSpawn: true })) + ); + expect(response.status).toBe(502); + expect(boundaries.cli).not.toHaveBeenCalled(); + if (mode === "http") expect(delegatedHeaders[0].get(denyHeader)).toBe("1"); + }); + } + + for (const value of ["0", "false", "allow"]) { + test(`a client marker value of ${value} cannot authorize remote CLI discovery`, async () => { + const conn = await connection(); + const response = await sync(conn.id, "remote", { + ...modelSync.buildModelSyncInternalHeaders(), + [denyHeader]: value, + [AUTHZ_HEADER_PEER_LOCALITY]: "loopback", + host: "127.0.0.1", + "x-forwarded-for": "127.0.0.1", + }); + expect(response.status).toBe(502); + expect(delegatedHeaders[0].get(denyHeader)).toBe("1"); + expect(boundaries.cli).not.toHaveBeenCalled(); + }); + } + + test("an internal token and forged allow value alone cannot authorize direct remote models", async () => { + const conn = await connection(); + const response = await modelsRoute.GET( + request(`/api/providers/${conn.id}/models?refresh=true`, "GET", "remote", { + ...modelSync.buildModelSyncInternalHeaders(), + [denyHeader]: "allow", + [AUTHZ_HEADER_PEER_LOCALITY]: "loopback", + }), + { params: { id: conn.id } } + ); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ source: "local_catalog" }); + expect(boundaries.cli).not.toHaveBeenCalled(); + }); + + test("the real policy rejects an invalid token carrying forged allow and locality", async () => { + const conn = await connection(); + const path = `/api/providers/${conn.id}/models`; + const incoming = request(path, "GET", "remote", { + [authHeader]: "invalid-token", + [denyHeader]: "allow", + [AUTHZ_HEADER_PEER_LOCALITY]: "loopback", + [PEER_IP_HEADER]: "forged|127.0.0.1", + }); + const decision = await managementPolicy.evaluate({ + request: { + method: incoming.method, + headers: incoming.headers, + url: incoming.url, + socket: { remoteAddress: "203.0.113.8" }, + }, + classification: classifyRoute(path, "GET"), + requestId: "cursor-denial-fixture", + }); + expect(decision).toMatchObject({ allow: false, status: 401 }); + expect(boundaries.cli).not.toHaveBeenCalled(); + }); + + test("sync rejects invalid internal authentication before creating a delegated token", async () => { + const conn = await connection(); + const response = await sync(conn.id, "loopback", { + [authHeader]: "invalid-token", + [denyHeader]: "allow", + }); + expect(response.status).toBe(401); + expect(globalThis.fetch).not.toHaveBeenCalled(); + expect(boundaries.cli).not.toHaveBeenCalled(); + }); + + test("a no-options internal local request retains direct CLI discovery", async () => { + const conn = await connection(); + const response = await modelsRoute.GET( + request( + `/api/providers/${conn.id}/models?refresh=true`, + "GET", + "loopback", + modelSync.buildModelSyncInternalHeaders() + ), + { params: { id: conn.id } } + ); + expect(response.status).toBe(200); + expect(boundaries.cli).toHaveBeenCalledTimes(1); + }); + + test("an authenticated denial still permits successful HTTP model discovery", async () => { + const conn = await connection(true); + boundaries.provider.mockResolvedValue( + Response.json({ + models: [{ name: "gpt-5.3-codex", displayName: "Codex 5.3" }], + }) + ); + const response = await sync( + conn.id, + "loopback", + modelSync.buildModelSyncInternalHeaders({ denyLocalSpawn: true }) + ); + expect(response.status).toBe(200); + expect(boundaries.provider).toHaveBeenCalledTimes(1); + expect(boundaries.cli).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/api/cursor-model-sync-locality.spec.ts b/tests/unit/api/cursor-model-sync-locality.spec.ts new file mode 100644 index 000000000000..c0c791fb34d0 --- /dev/null +++ b/tests/unit/api/cursor-model-sync-locality.spec.ts @@ -0,0 +1,469 @@ +import { EventEmitter } from "node:events"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { PassThrough } from "node:stream"; +import { setImmediate as yieldIo } from "node:timers/promises"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; + +const processBoundary = vi.hoisted(() => ({ + spawn: vi.fn(), + unexpected: vi.fn(() => { + throw new Error("unexpected subprocess in Cursor locality fixture"); + }), +})); + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + const replacements = { + spawn: processBoundary.spawn, + spawnSync: processBoundary.unexpected, + exec: processBoundary.unexpected, + execSync: processBoundary.unexpected, + execFile: processBoundary.unexpected, + execFileSync: processBoundary.unexpected, + fork: processBoundary.unexpected, + }; + return { ...actual, ...replacements, default: { ...actual, ...replacements } }; +}); + +const providerBoundary = vi.hoisted(() => ({ fetch: vi.fn() })); +vi.mock("@/shared/network/safeOutboundFetch", async (importOriginal) => ({ + ...(await importOriginal()), + safeOutboundFetch: providerBoundary.fetch, +})); +vi.mock("@/lib/usage/callLogs", () => ({ saveCallLog: vi.fn(async () => undefined) })); +vi.mock("@/lib/cli-helper/codexProfileAutoSync", () => ({ + autoSyncCodexProfilesFromLiveCatalog: vi.fn(async () => ({ ok: false, reason: "fixture" })), +})); +vi.mock("@/lib/cli-helper/claudeProfileAutoSync", () => ({ + autoSyncClaudeProfilesFromLiveCatalog: vi.fn(async () => ({ ok: false, reason: "fixture" })), +})); + +// Set the isolated data directory before every application import. The fake binary is only a +// resolver fixture: the early child_process mock above intercepts its execution. +const dataDir = mkdtempSync(join(tmpdir(), "cursor-sync-locality-")); +const originalEnv = { + DATA_DIR: process.env.DATA_DIR, + PATH: process.env.PATH, + JWT_SECRET: process.env.JWT_SECRET, + INITIAL_PASSWORD: process.env.INITIAL_PASSWORD, + OMNIROUTE_INTERNAL_SCHEME: process.env.OMNIROUTE_INTERNAL_SCHEME, + OMNIROUTE_BASE_PATH: process.env.OMNIROUTE_BASE_PATH, +}; +process.env.DATA_DIR = dataDir; +process.env.PATH = dataDir; +process.env.JWT_SECRET = "cursor-locality-isolated-session-secret"; +process.env.INITIAL_PASSWORD = "cursor-locality-isolated-password"; +delete process.env.OMNIROUTE_INTERNAL_SCHEME; +delete process.env.OMNIROUTE_BASE_PATH; +const fakeBinary = join(dataDir, "cursor-agent"); +writeFileSync(fakeBinary, "fixture only; never execute\n"); + +const core = await import("@/lib/db/core"); +const providers = await import("@/lib/db/providers"); +const models = await import("@/lib/db/models"); +const settings = await import("@/lib/db/settings"); +const modelSync = await import("@/shared/services/modelSyncScheduler"); +const syncRoute = await import("@/app/api/providers/[id]/sync-models/route"); +const modelsRoute = await import("@/app/api/providers/[id]/models/route"); +const { resolveCursorAgentBinary } = await import("@/lib/providerModels/cursorAgent"); +const { managementPolicy } = await import("@/server/authz/policies/management"); +const { classifyRoute } = await import("@/server/authz/classify"); +const { + AUTHZ_HEADER_PEER_LOCALITY, + AUTHZ_TRUSTED_HEADERS, + PEER_IP_HEADER, + VIA_PROXY_HEADER, + CLIENT_IP_HEADER, +} = await import("@/server/authz/headers"); +const { mintDashboardSessionToken } = await import("@/shared/utils/dashboardSessionToken"); + +type Locality = "loopback" | "lan" | "remote" | "unrecognized" | undefined; +type TransportMode = "http" | "readiness-down" | "network-down" | "http-error"; +type CatalogBody = { + source?: string; + warning?: string; + error?: unknown; + ok?: boolean; + models?: Array<{ id: string }>; +}; +const originalFetch = globalThis.fetch; +let sessionCookie: string; +let transportMode: TransportMode; +let readinessCalls: number; +let modelHttpCalls: number; +let innerRequests: Request[]; +let innerBodies: CatalogBody[]; +let onReadinessProbe: (() => void) | undefined; + +function childWithCatalog() { + const child = new EventEmitter(); + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const result = Object.assign(child, { stdout, stderr, kill: vi.fn() }); + queueMicrotask(() => { + stdout.write("Available models\n\nauto - Auto\ngpt-5.3-codex - Codex 5.3\n"); + child.emit("close", 0, null); + }); + return result; +} + +// Handler fixture for the ingress pipeline contract: delete ALL trusted client claims and +// transport-only peer stamps, then stamp only the actual fixture peer verdict. No Host, +// forwarded header, cookie or internal token can supply that verdict. +function ingressRequest( + url: string, + method: string, + peerLocality: Locality, + clientHeaders: HeadersInit = {} +): Request { + const headers = new Headers(clientHeaders); + for (const name of [ + ...AUTHZ_TRUSTED_HEADERS, + PEER_IP_HEADER, + VIA_PROXY_HEADER, + CLIENT_IP_HEADER, + ]) { + headers.delete(name); + } + if (peerLocality !== undefined) headers.set(AUTHZ_HEADER_PEER_LOCALITY, peerLocality); + return new Request(url, { method, headers }); +} + +async function policyDecision(request: Request, peerIp = "203.0.113.8") { + return managementPolicy.evaluate({ + request: { + method: request.method, + headers: request.headers, + url: request.url, + socket: { remoteAddress: peerIp }, + }, + classification: classifyRoute(new URL(request.url).pathname, request.method), + requestId: "cursor-locality-fixture", + }); +} + +async function connection( + options: { + provider?: "cursor" | "cursor-api"; + autoFetchModels?: boolean; + withToken?: boolean; + } = {} +) { + return providers.createProviderConnection({ + provider: options.provider ?? "cursor", + authType: "oauth", + name: "isolated Cursor locality fixture", + isActive: true, + ...(options.withToken ? { accessToken: "fixture-cursor-session" } : {}), + providerSpecificData: { autoFetchModels: options.autoFetchModels ?? true }, + }); +} + +async function sync(id: string, locality: Locality, headers: HeadersInit = {}) { + const request = ingressRequest( + `https://management.invalid/api/providers/${id}/sync-models?quiet=1`, + "POST", + locality, + { cookie: sessionCookie, ...Object.fromEntries(new Headers(headers)) } + ); + const decision = await policyDecision(request, locality === "loopback" ? "127.0.0.1" : undefined); + expect(decision.allow).toBe(true); + return syncRoute.POST(request, { params: Promise.resolve({ id }) }); +} + +function directModels(id: string, locality: Locality, refresh = true) { + return modelsRoute.GET( + ingressRequest( + `https://management.invalid/api/providers/${id}/models?refresh=${refresh}`, + "GET", + locality, + { cookie: sessionCookie } + ), + { params: { id } } + ); +} + +function expectCliProbe() { + expect(processBoundary.spawn).toHaveBeenCalledTimes(1); + const binary = resolveCursorAgentBinary(); + expect(basename(binary ?? "")).toBe("cursor-agent"); + expect(processBoundary.spawn).toHaveBeenCalledWith(binary, ["--list-models"], { + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); +} + +async function withFailedTransport( + mode: TransportMode, + operation: () => Promise +): Promise { + transportMode = mode; + vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); + try { + const firstProbe = new Promise((resolve) => { + onReadinessProbe = resolve; + }); + const result = operation(); + // Session verification uses real WebCrypto I/O. Start virtual time only after + // authentication has completed and the first transport probe actually began. + await firstProbe; + await vi.advanceTimersByTimeAsync(31_000); + return await result; + } finally { + onReadinessProbe = undefined; + vi.useRealTimers(); + } +} + +beforeAll(async () => { + await settings.updateSettings({ requireLogin: true }); + sessionCookie = `auth_token=${await mintDashboardSessionToken( + new TextEncoder().encode(process.env.JWT_SECRET) + )}`; +}); + +beforeEach(async () => { + processBoundary.spawn.mockReset().mockImplementation(childWithCatalog); + processBoundary.unexpected.mockClear(); + providerBoundary.fetch.mockReset().mockImplementation(async () => { + throw new Error("fixture AvailableModels unavailable"); + }); + await models.deleteSyncedAvailableModelsForProvider("cursor"); + await models.deleteSyncedAvailableModelsForProvider("cursor-api"); + syncRoute.__resetLoopbackReadinessForTests(); + transportMode = "http"; + readinessCalls = 0; + modelHttpCalls = 0; + innerRequests = []; + innerBodies = []; + globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(typeof input === "string" || input instanceof URL ? input : input.url); + expect(url.origin).toBe(new URL(modelSync.getModelSyncInternalBaseUrl()).origin); + expect(init?.redirect).toBe("error"); + if (url.pathname.includes("__readiness_probe__")) { + readinessCalls++; + onReadinessProbe?.(); + if (transportMode === "readiness-down") throw new TypeError("fixture ECONNREFUSED"); + return new Response(null, { status: 404 }); + } + modelHttpCalls++; + if (transportMode === "network-down") throw new TypeError("fixture fetch failed"); + if (transportMode === "http-error") + return Response.json({ error: "fixture HTTP failure" }, { status: 503 }); + expect(url.searchParams.get("refresh")).toBe("true"); + expect(url.searchParams.get("excludeCustom")).toBe("true"); + // The self-fetch's TCP peer is loopback regardless of the original caller. + const inner = ingressRequest(url.href, init?.method ?? "GET", "loopback", init?.headers); + expect(modelSync.isModelSyncInternalRequest(inner)).toBe(true); + expect((await policyDecision(inner, "127.0.0.1")).allow).toBe(true); + innerRequests.push(inner); + const id = decodeURIComponent(url.pathname.split("/").at(-2) ?? ""); + const response = await modelsRoute.GET(inner, { params: { id } }); + innerBodies.push((await response.clone().json()) as CatalogBody); + return response; + }) as typeof fetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + vi.useRealTimers(); + expect(processBoundary.unexpected).not.toHaveBeenCalled(); +}); + +afterAll(async () => { + await import("@/lib/db/quotaPools"); + await yieldIo(); + core.resetDbInstance(); + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + rmSync(dataDir, { recursive: true, force: true }); +}); + +describe("Cursor model-sync preserves the original local-execution permission", () => { + for (const locality of ["remote", "lan", undefined, "unrecognized"] as const) { + test(`HTTP delegation refuses CLI discovery from ${String(locality)} origin`, async () => { + const conn = await connection(); + await sync(conn.id, locality); + expect(modelHttpCalls).toBe(1); + expect(innerRequests[0].headers.get(AUTHZ_HEADER_PEER_LOCALITY)).toBe("loopback"); + expect(processBoundary.spawn).not.toHaveBeenCalled(); + }); + } + + test("sync refresh cannot turn autoFetchModels:false into remote CLI permission", async () => { + const conn = await connection({ autoFetchModels: false }); + await sync(conn.id, "remote"); + expect(modelHttpCalls).toBe(1); + expect(processBoundary.spawn).not.toHaveBeenCalled(); + }); + + test("failed HTTP discovery cannot grant a remote caller CLI permission", async () => { + const conn = await connection({ withToken: true }); + await sync(conn.id, "remote"); + expect(providerBoundary.fetch).toHaveBeenCalledTimes(1); + expect(processBoundary.spawn).not.toHaveBeenCalled(); + }); + + test("local HTTP sync reaches the real Cursor CLI helper", async () => { + const conn = await connection(); + const response = await sync(conn.id, "loopback"); + expect(response.status).toBe(200); + expect(innerBodies[0].source).toBe("api"); + expectCliProbe(); + }); + + test("intentional scheduler sync retains CLI discovery", async () => { + const conn = await connection(); + const internal = modelSync.buildModelSyncInternalHeaders(); + expect(modelSync.isModelSyncInternalRequest({ headers: new Headers(internal) })).toBe(true); + const response = await sync(conn.id, "loopback", { ...internal, cookie: "" }); + expect(response.status).toBe(200); + expectCliProbe(); + }); + + for (const mode of ["readiness-down", "network-down"] as const) { + for (const locality of mode === "readiness-down" + ? (["remote", "lan", undefined, "unrecognized", "loopback"] as const) + : (["remote", "loopback"] as const)) { + test(`${mode} in-process fallback preserves ${locality} permission`, async () => { + const conn = await connection(); + const response = await withFailedTransport(mode, () => sync(conn.id, locality)); + expect(innerRequests).toHaveLength(0); + expect(modelHttpCalls).toBe(mode === "network-down" ? 3 : 0); + expect(readinessCalls).toBe(mode === "network-down" ? 1 : 120); + if (locality === "loopback") { + expectCliProbe(); + expect(response.status).toBe(200); + } else { + expect(processBoundary.spawn).not.toHaveBeenCalled(); + expect(response.status).toBe(502); + } + }); + } + + test(`${mode} preserves intentional internal scheduler permission`, async () => { + const conn = await connection(); + const response = await withFailedTransport(mode, () => + sync(conn.id, "loopback", { ...modelSync.buildModelSyncInternalHeaders(), cookie: "" }) + ); + expectCliProbe(); + expect(response.status).toBe(200); + }); + } + + test("an HTTP error is returned without retrying or invoking the in-process fallback", async () => { + const conn = await connection(); + transportMode = "http-error"; + const response = await sync(conn.id, "loopback"); + expect(response.status).toBe(503); + expect(modelHttpCalls).toBe(1); + expect(processBoundary.spawn).not.toHaveBeenCalled(); + }); + + test("client-supplied trusted locality and forwarded loopback claims cannot grant permission", async () => { + const conn = await connection(); + await sync(conn.id, "remote", { + host: "localhost", + "x-forwarded-for": "127.0.0.1", + [AUTHZ_HEADER_PEER_LOCALITY]: "loopback", + [PEER_IP_HEADER]: "forged|127.0.0.1", + [VIA_PROXY_HEADER]: "forged|0", + }); + expect(processBoundary.spawn).not.toHaveBeenCalled(); + }); + + for (const suffix of ["models", "sync-models"]) { + test(`real management policy rejects invalid internal auth for ${suffix} when login is enabled`, async () => { + modelSync.buildModelSyncInternalHeaders(); + const request = ingressRequest( + `http://localhost/api/providers/fixture/${suffix}`, + suffix === "models" ? "GET" : "POST", + "remote", + { + [modelSync.getModelSyncInternalAuthHeaderName()]: "invalid-token", + [AUTHZ_HEADER_PEER_LOCALITY]: "loopback", + "x-omniroute-auth-kind": "management_key", + "x-omniroute-auth-id": "model-sync", + [PEER_IP_HEADER]: "forged|127.0.0.1", + } + ); + expect(modelSync.isModelSyncInternalRequest(request)).toBe(false); + expect(await policyDecision(request)).toMatchObject({ allow: false, status: 401 }); + expect(processBoundary.spawn).not.toHaveBeenCalled(); + }); + } + + test("an invalid internal token alongside a valid dashboard session cannot grant CLI permission", async () => { + const conn = await connection(); + await sync(conn.id, "remote", { + [modelSync.getModelSyncInternalAuthHeaderName()]: "invalid-token", + }); + expect(processBoundary.spawn).not.toHaveBeenCalled(); + }); + + for (const locality of ["remote", "lan", undefined, "unrecognized"] as const) { + test(`direct models gate refuses CLI from ${String(locality)}`, async () => { + const conn = await connection(); + const response = await directModels(conn.id, locality); + const body = (await response.json()) as CatalogBody; + expect(response.status).toBe(200); + expect(body.source).toBe("local_catalog"); + expect(body.warning).toMatch(/requires a local request/); + expect(processBoundary.spawn).not.toHaveBeenCalled(); + }); + } + + test("direct local models discovery reaches the actual subprocess boundary", async () => { + const conn = await connection(); + const response = await directModels(conn.id, "loopback"); + expect(response.status).toBe(200); + expectCliProbe(); + }); + + test("remote sync still accepts successful HTTP discovery", async () => { + const conn = await connection({ withToken: true }); + providerBoundary.fetch.mockResolvedValue( + Response.json({ + models: [{ name: "gpt-5.3-codex", displayName: "Codex 5.3" }], + }) + ); + const response = await sync(conn.id, "remote"); + expect(response.status).toBe(200); + expect((await response.json()) as CatalogBody).toMatchObject({ ok: true }); + expect(innerBodies[0]).toMatchObject({ source: "api" }); + expect(providerBoundary.fetch).toHaveBeenCalledTimes(1); + expect(processBoundary.spawn).not.toHaveBeenCalled(); + }); + + test("remote direct refresh keeps the cached catalog when HTTP discovery fails", async () => { + const conn = await connection({ withToken: true }); + await models.replaceSyncedAvailableModelsForConnection("cursor", conn.id, [ + { id: "gpt-5.3-codex", name: "Cached Codex" }, + ]); + const response = await directModels(conn.id, "remote"); + const body = (await response.json()) as CatalogBody; + expect(response.status).toBe(200); + expect(body.source).toBe("cache"); + expect(body.models?.some((entry) => entry.id === "gpt-5.3-codex")).toBe(true); + expect(processBoundary.spawn).not.toHaveBeenCalled(); + }); + + test("remote direct request honors disabled auto-fetch without probing", async () => { + const conn = await connection({ autoFetchModels: false, withToken: true }); + const response = await directModels(conn.id, "remote", false); + expect(response.status).toBe(200); + expect(providerBoundary.fetch).not.toHaveBeenCalled(); + expect(processBoundary.spawn).not.toHaveBeenCalled(); + }); + + test("cursor-api never borrows the host CLI catalog, including local sync", async () => { + const conn = await connection({ provider: "cursor-api", withToken: true }); + await sync(conn.id, "loopback"); + expect(providerBoundary.fetch).toHaveBeenCalledTimes(1); + expect(processBoundary.spawn).not.toHaveBeenCalled(); + }); +});