From 5fbd6019d8fd53c300cefee92a7232e73d97f085 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Thu, 3 Sep 2026 17:19:25 +0900 Subject: [PATCH] fix(cursor): remove spoofable connection status --- .../integrations/CursorIntegrationPage.tsx | 28 +++------------- gui/src/pages/integrations/cursor-api.ts | 9 ----- .../pages/integrations/overview-clients.ts | 15 ++++----- gui/tests/cursor-integration-page.test.tsx | 33 ++----------------- gui/tests/integrations-overview-rows.test.ts | 5 ++- src/integrations/cursor-seen.ts | 31 ----------------- src/server/index.ts | 4 --- .../management/cursor-integration-routes.ts | 6 +--- tests/cursor-integration-status.test.ts | 29 +++------------- 9 files changed, 20 insertions(+), 140 deletions(-) delete mode 100644 src/integrations/cursor-seen.ts diff --git a/gui/src/pages/integrations/CursorIntegrationPage.tsx b/gui/src/pages/integrations/CursorIntegrationPage.tsx index 1036dc624b..e489ee7dc6 100644 --- a/gui/src/pages/integrations/CursorIntegrationPage.tsx +++ b/gui/src/pages/integrations/CursorIntegrationPage.tsx @@ -5,8 +5,7 @@ import { formatTokens } from "../../format-tokens"; import { navigateHash } from "../../hash-routing"; import { useI18n, useT, type TKey } from "../../i18n/shared"; import { Notice } from "../../ui"; -import { formatRelativeTime, relativeTimeLabelsFromT } from "../../provider-workspace/usage"; -import { CURSOR_SEEN_WINDOW_MS, loadCursorIntegrationStatus, type CursorIntegrationStatus } from "./cursor-api"; +import { loadCursorIntegrationStatus, type CursorIntegrationStatus } from "./cursor-api"; /** * The Cursor tab is a read-only companion, not a switch. @@ -14,8 +13,8 @@ import { CURSOR_SEEN_WINDOW_MS, loadCursorIntegrationStatus, type CursorIntegrat * Cursor Private Inference keeps its gateway settings in a SQLite database the running app * rewrites and its API key in the OS keychain, both out of bounds for this proxy. So the page * does the three things it can do honestly: say which Cursor builds are installed, hand the - * user the two values Cursor's own form wants, and report whether a Cursor client has called - * us since the proxy started. Everything shown is a GET of one status route. + * user the two values Cursor's own form wants, and predict the model controls it will render. + * Everything shown is a GET of one status route. */ function CopyValue({ value, label }: { value: string; label: string }) { @@ -61,20 +60,16 @@ function DetectionRow({ labelKey, installed, path, version }: { labelKey: TKey; export default function CursorIntegrationPage({ apiBase, active }: { apiBase: string; active: boolean }) { const { t, locale } = useI18n(); - // The clock is sampled when a payload arrives, never during render: the "seen within 24h" - // badge and the relative time must agree with each other and stay stable across re-renders. - const [sampledAt, setSampledAt] = useState(() => Date.now()); const fetchStatus = useCallback( async (signal: AbortSignal) => { const payload = await loadCursorIntegrationStatus(apiBase, signal); // The overview paints a null read as "unknown"; the page has room to say why. if (!payload) throw new Error("cursor status unavailable"); - setSampledAt(Date.now()); return payload; }, [apiBase], ); - // Polls while the tab is open so "Refresh model list" in Cursor shows up here within seconds. + // Poll while the tab is open so install and catalog changes show up without a reload. const resource = useDataSurface( `integration-cursor-page:${apiBase}`, [apiBase], @@ -82,8 +77,6 @@ export default function CursorIntegrationPage({ apiBase, active }: { apiBase: st { isEmpty: () => false, enabled: active, pollMs: 15_000, pauseWhenHidden: true }, ); const status = resource.state.data ?? null; - const labels = relativeTimeLabelsFromT(t); - return (

{t("integrations.cursor.title")}

@@ -124,19 +117,6 @@ export default function CursorIntegrationPage({ apiBase, active }: { apiBase: st )} -
-

{t("integrations.cursor.connection")}

- {status.lastSeen - ? ( -

- - {t("integrations.cursor.seen", { time: formatRelativeTime(status.lastSeen.at, labels, sampledAt), ua: status.lastSeen.userAgent })} - -

- ) - :

{t("integrations.cursor.neverSeen")}

} -
-

{t("integrations.cursor.models")}

{t("integrations.cursor.modelsHint")}

diff --git a/gui/src/pages/integrations/cursor-api.ts b/gui/src/pages/integrations/cursor-api.ts index f3f23a3538..a2264a7022 100644 --- a/gui/src/pages/integrations/cursor-api.ts +++ b/gui/src/pages/integrations/cursor-api.ts @@ -4,11 +4,6 @@ */ import { readJsonIfOk } from "../../fetch-json"; -export interface CursorSeen { - at: number; - userAgent: string; -} - export interface CursorModelExpectation { id: string; reasoning: string[] | null; @@ -19,7 +14,6 @@ export interface CursorIntegrationStatus { privateInference: { installed: boolean; path: string | null; version: string | null }; regularCursor: { installed: boolean; path: string | null }; gateway: { baseUrl: string; apiKeyMode: "credential" | "placeholder"; placeholder: string }; - lastSeen: CursorSeen | null; models: CursorModelExpectation[]; guideUrl: string; } @@ -36,6 +30,3 @@ export async function loadCursorIntegrationStatus(apiBase: string, signal?: Abor return null; } } - -/** 24h is the window inside which a Cursor request counts as "connected". */ -export const CURSOR_SEEN_WINDOW_MS = 24 * 60 * 60 * 1000; diff --git a/gui/src/pages/integrations/overview-clients.ts b/gui/src/pages/integrations/overview-clients.ts index df456e438a..e791923f4e 100644 --- a/gui/src/pages/integrations/overview-clients.ts +++ b/gui/src/pages/integrations/overview-clients.ts @@ -21,7 +21,7 @@ import { type IntegrationStatus, } from "./integration-api"; import type { NativeIntegrationClientId, NativeStatus } from "./native-api"; -import { CURSOR_SEEN_WINDOW_MS, type CursorIntegrationStatus } from "./cursor-api"; +import type { CursorIntegrationStatus } from "./cursor-api"; export type OverviewClientId = | "codex" @@ -438,10 +438,10 @@ function grokRow( /** - * Cursor has no switch: its gateway is configured inside Cursor, and this proxy never - * writes there. "Applied" therefore means a Cursor client actually called us recently. + * Cursor has no switch: its gateway is configured inside Cursor, and this proxy cannot + * verify that configuration. Installation detection must not be reported as "applied". */ -function cursorRow(payload: CursorIntegrationStatus | null, now = Date.now()): OverviewRow { +function cursorRow(payload: CursorIntegrationStatus | null): OverviewRow { const base = { id: "cursor" as const, hash: "integrations/cursor", @@ -457,13 +457,12 @@ function cursorRow(payload: CursorIntegrationStatus | null, now = Date.now()): O if (!payload.privateInference.installed) { return { ...base, state: "not-installed", installed: false, applied: false, detailKey: "integrations.detail.cursorAbsent" }; } - const seenRecently = payload.lastSeen !== null && now - payload.lastSeen.at < CURSOR_SEEN_WINDOW_MS; return { ...base, - state: seenRecently ? "current" : "absent", + state: "absent", installed: true, - applied: seenRecently, - detailKey: seenRecently ? "integrations.detail.cursorSeen" : "integrations.detail.cursorNeverSeen", + applied: false, + detailKey: null, }; } diff --git a/gui/tests/cursor-integration-page.test.tsx b/gui/tests/cursor-integration-page.test.tsx index fdaabf9d16..d2c41a7bc5 100644 --- a/gui/tests/cursor-integration-page.test.tsx +++ b/gui/tests/cursor-integration-page.test.tsx @@ -32,7 +32,6 @@ function payload(overrides: Partial = {}): CursorIntegr privateInference: { installed: true, path: "/Applications/Cursor Private Inference.app", version: "3.18.25" }, regularCursor: { installed: true, path: "/Applications/Cursor.app" }, gateway: { baseUrl: "http://127.0.0.1:10100/v1", apiKeyMode: "placeholder", placeholder: "opencodex" }, - lastSeen: null, models: [ { id: "gpt-5.6-sol", reasoning: ["low", "medium", "high", "xhigh"], context: { defaultWindow: 272_000, longWindow: 922_000 } }, { id: "kimi/k3", reasoning: null, context: null }, @@ -113,29 +112,6 @@ test("reads its own status route and renders the gateway values with copy button expect(copies.length).toBe(2); }); -test("a never-seen install tells the user to press Refresh model list", async () => { - await mount(); - expect(textOf()).toContain("Refresh model list in Cursor"); - expect(container.querySelector("[data-seen='false']")).not.toBeNull(); -}); - -test("a recent request renders the relative time and the user agent", async () => { - statusResponse = () => json(payload({ lastSeen: { at: Date.now() - 3 * 60_000, userAgent: "Cursor/3.18.25" } })); - await mount(); - const text = textOf(); - expect(text).toContain("Cursor/3.18.25"); - expect(text).toContain("3m ago"); - expect(container.querySelector("[data-seen='true'] .badge-green")).not.toBeNull(); -}); - -test("a stale request keeps the timestamp but drops the green badge", async () => { - statusResponse = () => json(payload({ lastSeen: { at: Date.now() - 3 * 86_400_000, userAgent: "Cursor/3.18.25" } })); - await mount(); - expect(textOf()).toContain("3d ago"); - expect(container.querySelector("[data-seen='true'] .badge-green")).toBeNull(); - expect(container.querySelector("[data-seen='true'] .badge-muted")).not.toBeNull(); -}); - test("regular Cursor alone gets the tunnel explanation, not a gateway promise", async () => { statusResponse = () => json(payload({ privateInference: { installed: false, path: null, version: null } })); await mount(); @@ -273,17 +249,12 @@ test("overview: an unreadable source is unknown, not 'not installed'", () => { expect(row.toggle).toBeNull(); }); -test("overview: installed but never seen is absent; a recent request is current and applied", () => { +test("overview: an installed Cursor remains unapplied because its configuration cannot be verified", () => { const idle = cursorRow(payload()); expect(idle.state).toBe("absent"); expect(idle.installed).toBe(true); expect(idle.applied).toBe(false); - expect(idle.detailKey).toBe("integrations.detail.cursorNeverSeen"); - - const seen = cursorRow(payload({ lastSeen: { at: Date.now() - 60_000, userAgent: "Cursor/3.18.25" } })); - expect(seen.state).toBe("current"); - expect(seen.applied).toBe(true); - expect(seen.detailKey).toBe("integrations.detail.cursorSeen"); + expect(idle.detailKey).toBeNull(); const missing = cursorRow(payload({ privateInference: { installed: false, path: null, version: null } })); expect(missing.state).toBe("not-installed"); diff --git a/gui/tests/integrations-overview-rows.test.ts b/gui/tests/integrations-overview-rows.test.ts index 5fe85be752..6205c999f7 100644 --- a/gui/tests/integrations-overview-rows.test.ts +++ b/gui/tests/integrations-overview-rows.test.ts @@ -217,15 +217,14 @@ test("every client counts toward the summary, not just the file clients", () => privateInference: { installed: true, path: "/Applications/Cursor Private Inference.app", version: "3.18.25" }, regularCursor: { installed: false, path: null }, gateway: { baseUrl: "http://127.0.0.1:10100/v1", apiKeyMode: "placeholder", placeholder: "opencodex-loopback" }, - lastSeen: { at: Date.now() - 60_000, userAgent: "Cursor/3.18.25" }, models: [], guideUrl: "https://example.invalid/guide", }, })); const counts = countOverviewRows(rows.rows); - // codex + claude + desktop + grok + cursor + opencode. Keys are deliberately absent: + // codex + claude + desktop + grok + opencode. Keys and Cursor are deliberately absent: // an issued credential is not an applied client. - expect(counts.applied).toBe(6); + expect(counts.applied).toBe(5); expect(counts.stale).toBe(1); expect(counts.unknown).toBe(0); }); diff --git a/src/integrations/cursor-seen.ts b/src/integrations/cursor-seen.ts deleted file mode 100644 index df4ba9a227..0000000000 --- a/src/integrations/cursor-seen.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Remember the last time a Cursor client asked this proxy for its model list. - * - * The Integrations page cannot read Cursor's own settings (and must not write them), so - * "is Cursor pointed at me?" is answered from our side: Cursor's local-agent runtime sends - * `User-Agent: Cursor/` on `GET /v1/models`. Only that header value and a - * timestamp are kept, in memory, so a proxy restart forgets it and the card says so. - */ -// Attacker-controlled header: accept only the shape Cursor sends and keep it short. -const CURSOR_USER_AGENT = /^Cursor\/[\w.+-]{1,40}$/; - -export interface CursorSeen { - at: number; - userAgent: string; -} - -let last: CursorSeen | null = null; - -export function recordCursorSeen(headers: Headers, now = Date.now()): void { - const userAgent = headers.get("user-agent")?.trim() ?? ""; - if (!CURSOR_USER_AGENT.test(userAgent)) return; - last = { at: now, userAgent }; -} - -export function cursorLastSeen(): CursorSeen | null { - return last ? { ...last } : null; -} - -export function resetCursorSeenForTests(): void { - last = null; -} diff --git a/src/server/index.ts b/src/server/index.ts index eab0ba1d7f..007c966a22 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -226,7 +226,6 @@ import { import { detectInstall } from "../update/index"; import { readyProtocolMetadata } from "../remote/protocol"; import { modelCapabilityFields } from "./models-capabilities"; -import { recordCursorSeen } from "../integrations/cursor-seen"; export const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024; const WEBSOCKET_IDLE_TIMEOUT_SECONDS = 0; @@ -1332,9 +1331,6 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server { }); }); -describe("cursor last-seen recorder", () => { - beforeEach(() => resetCursorSeenForTests()); - afterEach(() => resetCursorSeenForTests()); - - test("records only a Cursor user agent, bounded and validated", () => { - recordCursorSeen(new Headers({ "user-agent": "curl/8.7.1" }), 1000); - expect(cursorLastSeen()).toBeNull(); - recordCursorSeen(new Headers({ "user-agent": "Cursor/3.18.25" }), 2000); - expect(cursorLastSeen()).toEqual({ at: 2000, userAgent: "Cursor/3.18.25" }); - // A padded or oversized value is not the shape Cursor sends and is ignored. - recordCursorSeen(new Headers({ "user-agent": `Cursor/${"x".repeat(60)}` }), 3000); - recordCursorSeen(new Headers({ "user-agent": "Cursor/3.18.25