feat(cursor): surfaces a dismissible cursor-agent nudge

Adds GET /api/providers/cursor/agent-availability, a credential-free
LOCAL_ONLY route returning only { cursorAgentAvailable: boolean },
backed by a 5-minute cached wrapper around the renewal orchestrator's
existing availability check. Surfaces a dismissible dashboard banner
on the Cursor provider page suggesting cursor-agent installation
when it isn't detected, following the existing dismissible-banner
convention. Also fixes a pre-existing bracket character in a
routeGuard.ts comment that was silently truncating
check-openapi-security-tiers.mjs's view of LOCAL_ONLY_API_PREFIXES.
This commit is contained in:
Will Gordon
2026-07-31 12:04:21 -04:00
committed by diegosouzapw
parent df3b691809
commit 21c68ec86e
13 changed files with 641 additions and 1 deletions

View File

@@ -1744,6 +1744,16 @@ paths:
"200":
description: Provider model list
/api/providers/cursor/agent-availability:
get:
tags: [Providers]
summary: Check cursor-agent availability
description: "Credential-free, informational check for whether cursor-agent is installed and authenticated on this host — backs the dashboard's dismissible install-nudge banner. Returns only cursorAgentAvailable (boolean); never tokens or machineId."
x-loopback-only: true
responses:
"200":
description: Availability result
/api/providers/test-batch:
post:
tags: [Providers]

View File

@@ -49,6 +49,7 @@ import CoolingConnectionsPanel from "./components/CoolingConnectionsPanel";
import ConnectionsHeaderToolbar from "./components/ConnectionsHeaderToolbar";
import ProviderAccountRoutingCard from "../../settings/components/ProviderAccountRoutingCard";
import ZedImportCard from "./components/ZedImportCard";
import CursorAgentNudge from "./components/CursorAgentNudge";
import ProviderPageHeader from "./components/ProviderPageHeader";
import CompatibleNodeCard from "./components/CompatibleNodeCard";
import ProviderModalsPanel from "./components/ProviderModalsPanel";
@@ -462,6 +463,7 @@ export default function ProviderDetailPageClient() {
{providerId === "zed" && (
<ZedImportCard fetchConnections={fetchConnections} notify={notify} />
)}
{providerId === "cursor" && <CursorAgentNudge />}
{isCompatible && providerNode && (
<CompatibleNodeCard
providerId={providerId}

View File

@@ -0,0 +1,102 @@
"use client";
import { useEffect, useState, useSyncExternalStore } from "react";
import { useTranslations } from "next-intl";
const DISMISS_STORAGE_KEY = "omniroute.cursorAgentNudgeDismissed";
// Same-tab signal for the dismiss button, since writing localStorage doesn't
// fire a "storage" event in the tab that wrote it.
const DISMISS_EVENT = "omniroute:cursor-agent-nudge-dismissed";
function isNotDismissed(): boolean {
try {
return !localStorage.getItem(DISMISS_STORAGE_KEY);
} catch {
return true;
}
}
function subscribe(callback: () => void) {
window.addEventListener(DISMISS_EVENT, callback);
return () => window.removeEventListener(DISMISS_EVENT, callback);
}
// SSR has no localStorage, so the server always renders the banner visible;
// useSyncExternalStore reconciles that against the real client-side value
// right after hydration, with no hydration mismatch and no setState-in-effect.
function getServerSnapshot() {
return true;
}
/**
* Dismissible dashboard banner suggesting `cursor-agent` installation when
* it's not detected on the host — without it, Cursor connections need
* periodic manual reconnection roughly every 24 hours instead of automatic
* background renewal. Fetches the credential-free, LOCAL_ONLY
* `/api/providers/cursor/agent-availability` endpoint — NOT
* `/api/oauth/cursor/auto-import`, which returns a live token/machineId and
* has no legitimate use here. One global dismissible notice (persisted under
* a single fixed key) since this is a host-level, not per-connection, fact.
*/
export default function CursorAgentNudge() {
const t = useTranslations("providers");
const visible = useSyncExternalStore(subscribe, isNotDismissed, getServerSnapshot);
const [available, setAvailable] = useState<boolean | null>(null);
useEffect(() => {
let cancelled = false;
fetch("/api/providers/cursor/agent-availability")
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (!cancelled && data && typeof data.cursorAgentAvailable === "boolean") {
setAvailable(data.cursorAgentAvailable);
}
})
.catch(() => {
// Unreachable (e.g. a non-loopback dashboard session gets 403
// LOCAL_ONLY here) — stay in the "unknown" state rather than nagging
// a session that simply can't reach the check.
});
return () => {
cancelled = true;
};
}, []);
if (!visible || available !== false) return null;
const dismiss = () => {
try {
localStorage.setItem(DISMISS_STORAGE_KEY, "true");
} catch {
// ignore — worst case the banner reappears next visit
}
window.dispatchEvent(new Event(DISMISS_EVENT));
};
return (
<div
role="complementary"
aria-label={t("cursorAgentNudgeTitle") || "Enable automatic Cursor session renewal"}
className="flex items-start gap-3 rounded-xl border border-blue-500/30 bg-blue-500/5 px-4 py-3"
>
<span className="material-symbols-outlined text-blue-500 shrink-0 mt-0.5">info</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-blue-700 dark:text-blue-400">
{t("cursorAgentNudgeTitle") || "Enable automatic Cursor session renewal"}
</p>
<p className="text-xs text-blue-600/80 dark:text-blue-300/70 mt-0.5">
{t("cursorAgentNudgeBody") ||
"Install cursor-agent for automatic session renewal — without it, Cursor connections need periodic manual reconnection roughly every 24 hours."}
</p>
</div>
<button
type="button"
onClick={dismiss}
aria-label={t("cursorAgentNudgeDismiss") || "Dismiss"}
className="shrink-0 text-blue-500 hover:text-blue-400 transition-colors"
>
<span className="material-symbols-outlined text-[18px]">close</span>
</button>
</div>
);
}

View File

@@ -0,0 +1,178 @@
// @vitest-environment jsdom
/**
* CursorAgentNudge (Cursor renewal plan, Task 5) — dismissible dashboard
* banner suggesting `cursor-agent` installation when it's unavailable.
* Mirrors tests/unit/ui/kimiSponsorBanner.test.tsx's technique for the same
* useSyncExternalStore + localStorage dismissal pattern (KimiSponsorBanner.tsx
* is the precedent this component follows), and this directory's own
* __tests__/phase1d.test.tsx for the createRoot/act mounting convention.
*/
import React from "react";
import { act } from "react";
import { createRoot, hydrateRoot } from "react-dom/client";
import { renderToString } from "react-dom/server";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import CursorAgentNudge from "../CursorAgentNudge";
const STORAGE_KEY = "omniroute.cursorAgentNudgeDismissed";
vi.mock("next-intl", () => ({ useTranslations: () => (k: string) => k }));
function mockFetch(cursorAgentAvailable: boolean | null) {
return vi.fn(async (url: string) => {
if (cursorAgentAvailable === null) {
return { ok: false, json: async () => ({}) } as Response;
}
return { ok: true, json: async () => ({ cursorAgentAvailable }) } as Response;
});
}
async function flushEffects() {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
function renderNudge(): HTMLDivElement {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(<CursorAgentNudge />);
});
return container;
}
describe("CursorAgentNudge", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
localStorage.removeItem(STORAGE_KEY);
});
afterEach(() => {
document.body.innerHTML = "";
localStorage.removeItem(STORAGE_KEY);
vi.unstubAllGlobals();
});
it("renders when cursorAgentAvailable is false", async () => {
vi.stubGlobal("fetch", mockFetch(false));
const container = renderNudge();
await flushEffects();
expect(container.querySelector("[role='complementary']")).not.toBeNull();
});
it("does not render when cursorAgentAvailable is true", async () => {
vi.stubGlobal("fetch", mockFetch(true));
const container = renderNudge();
await flushEffects();
expect(container.querySelector("[role='complementary']")).toBeNull();
});
it("does not render while the availability check is still pending or unreachable", async () => {
vi.stubGlobal("fetch", mockFetch(null)); // res.ok === false -> stays in the "unknown" state
const container = renderNudge();
await flushEffects();
expect(container.querySelector("[role='complementary']")).toBeNull();
});
it("fetches /api/providers/cursor/agent-availability, NOT /api/oauth/cursor/auto-import", async () => {
const fetchMock = mockFetch(false);
vi.stubGlobal("fetch", fetchMock);
renderNudge();
await flushEffects();
expect(fetchMock).toHaveBeenCalledWith("/api/providers/cursor/agent-availability");
for (const call of fetchMock.mock.calls) {
expect(String(call[0])).not.toContain("/api/oauth/cursor/auto-import");
}
});
it("dismiss button hides the banner and persists the dismissal to localStorage", async () => {
vi.stubGlobal("fetch", mockFetch(false));
const container = renderNudge();
await flushEffects();
expect(container.querySelector("[role='complementary']")).not.toBeNull();
const dismissButton = container.querySelector("button");
expect(dismissButton).not.toBeNull();
act(() => {
dismissButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.querySelector("[role='complementary']")).toBeNull();
expect(localStorage.getItem(STORAGE_KEY)).toBe("true");
});
it("stays hidden across a fresh render once dismissed (localStorage persistence — no reappear)", async () => {
localStorage.setItem(STORAGE_KEY, "true");
vi.stubGlobal("fetch", mockFetch(false)); // still "unavailable" — dismissal alone must suppress it
const container = renderNudge();
await flushEffects();
expect(container.querySelector("[role='complementary']")).toBeNull();
});
it("stays hidden after an actual unmount+remount of the same dismissed state", async () => {
vi.stubGlobal("fetch", mockFetch(false));
const first = document.createElement("div");
document.body.appendChild(first);
const firstRoot = createRoot(first);
act(() => {
firstRoot.render(<CursorAgentNudge />);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
const dismissButton = first.querySelector("button");
act(() => {
dismissButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
act(() => {
firstRoot.unmount();
});
first.remove();
const second = renderNudge();
await flushEffects();
expect(second.querySelector("[role='complementary']")).toBeNull();
});
it("hydrates without a mismatch warning (SSR always renders nothing before the fetch resolves)", async () => {
vi.stubGlobal("fetch", mockFetch(false));
const serverHtml = renderToString(<CursorAgentNudge />);
// The component renders null on the server (available starts at null,
// getServerSnapshot() returns true but `available !== false` short-circuits
// the render to null regardless) — this proves that invariant holds.
expect(serverHtml).toBe("");
const container = document.createElement("div");
container.innerHTML = serverHtml;
document.body.appendChild(container);
const errors: unknown[][] = [];
const originalConsoleError = console.error;
console.error = (...args: unknown[]) => {
errors.push(args);
};
try {
act(() => {
hydrateRoot(container, <CursorAgentNudge />);
});
} finally {
console.error = originalConsoleError;
}
const hydrationWarnings = errors.filter((args) =>
args.some((a) => typeof a === "string" && /hydrat/i.test(a))
);
expect(hydrationWarnings).toEqual([]);
});
});

View File

@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import { getCachedCursorAgentAvailability } from "@/lib/cursor/renewal";
/**
* GET /api/providers/cursor/agent-availability
* Credential-free, informational check for whether `cursor-agent` is
* installed and authenticated on this host — backs the dashboard's
* dismissible install-nudge banner. Returns ONLY `{ cursorAgentAvailable }`;
* never tokens/machineId. This is a SEPARATE route from
* `/api/oauth/cursor/auto-import` (which legitimately returns
* `accessToken`/`machineId` for its own credential-import purpose) —
* reusing that route here would hand a live local Cursor OAuth token to a
* frequently-mounted, purely informational UI component with no legitimate
* use for it.
*
* No in-route auth guard: unlike `/api/oauth/cursor/auto-import` (which is
* PUBLIC-classified and never reaches the LOCAL_ONLY gate), this route lives
* under `/api/providers/` — MANAGEMENT-classified — and is itself
* LOCAL_ONLY (see `LOCAL_ONLY_API_PREFIXES` in
* `src/server/authz/routeGuard.ts`), so `managementPolicy` already enforces
* auth + loopback before this handler runs, matching the sibling
* `/api/providers/[id]/refresh` and `/api/providers/[id]/login` routes
* (neither perform their own in-route auth check either).
*
* 🔒 LOCAL_ONLY — spawns `cursor-agent status --format json` via
* `checkCursorAgentAvailability()` (Hard Rules #15 + #17).
*/
export async function GET() {
const { available } = await getCachedCursorAgentAvailability();
return NextResponse.json({ cursorAgentAvailable: available });
}

View File

@@ -5637,6 +5637,9 @@
"zedPasteApiKey": "Paste API key…",
"zedSaving": "Saving…",
"zedImportAction": "Import",
"cursorAgentNudgeTitle": "Enable automatic Cursor session renewal",
"cursorAgentNudgeBody": "Install cursor-agent for automatic session renewal — without it, Cursor connections need periodic manual reconnection roughly every 24 hours.",
"cursorAgentNudgeDismiss": "Dismiss",
"zedManualImportFailed": "Manual import failed",
"zedManualImportSuccess": "Imported {provider} token from Zed",
"grokImportTitle": "Import Grok Build Auth",

View File

@@ -103,6 +103,34 @@ export async function checkCursorAgentAvailability(): Promise<{
}
}
const CURSOR_AGENT_AVAILABILITY_CACHE_TTL_MS = 5 * 60 * 1000;
let cachedAvailability: {
result: { available: boolean; binaryPath: string | null };
expiresAt: number;
} | null = null;
/**
* Cached wrapper around checkCursorAgentAvailability(), for INFORMATIONAL/UI
* callers only (5-minute TTL) — e.g. the dashboard's install-nudge banner,
* which may mount/refetch frequently. Task 3's sweep and Task 4's manual
* refresh route continue calling the UNCACHED checkCursorAgentAvailability()
* directly: a "refresh now" click must always see a fresh status, never a
* stale cached answer.
*/
export async function getCachedCursorAgentAvailability(): Promise<{
available: boolean;
binaryPath: string | null;
}> {
const now = Date.now();
if (cachedAvailability && cachedAvailability.expiresAt > now) {
return cachedAvailability.result;
}
const result = await checkCursorAgentAvailability();
cachedAvailability = { result, expiresAt: now + CURSOR_AGENT_AVAILABILITY_CACHE_TTL_MS };
return result;
}
export type CursorRenewalResult =
| {
status: "renewed";

View File

@@ -53,11 +53,12 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray<string> = [
"/api/local/", // T-12: 1-click local service launchers (Redis today; spawns podman/docker) — loopback-enforced by isLocalRequestAllowed() in src/lib/security/localEndpoints.ts (Hard Rules #15 + #17)
"/api/headroom/start", // Headroom token-saver proxy lifecycle: spawns headroom-ai python CLI (Hard Rules #15 + #17)
"/api/headroom/stop", // Headroom token-saver proxy lifecycle: sends SIGTERM/SIGKILL to managed PID (Hard Rules #15 + #17)
"/api/oauth/cursor/auto-import", // spawns `execFile("which", ["cursor"])` to verify a local Cursor install before importing creds — RCE-via-tunnel surface (Hard Rules #15 + #17, found by 6A.8 route-guard gate). Specific path only: the rest of /api/oauth/ (browser redirect/callback flows) must stay remote-reachable.
"/api/oauth/cursor/auto-import", // spawns `execFile("which", "cursor")` to verify a local Cursor install before importing creds — RCE-via-tunnel surface (Hard Rules #15 + #17, found by 6A.8 route-guard gate). Specific path only: the rest of /api/oauth/ (browser redirect/callback flows) must stay remote-reachable.
"/api/skills/collect/", // Skill Collector CLI detection: GET .../detect probes getCliRuntimeStatus() per CLI_TOOL_IDS entry, which spawns a child process to check each tool — RCE-via-tunnel surface (Hard Rules #15 + #17, PR #6294 review).
"/api/discovery/", // Discovery tool (opt-in provider scanner): the scan route makes outbound probes to provider endpoints (SSRF-adjacent) and the whole surface is an admin research tool — strict-loopback only, no manage-scope bypass (NOT in LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES). See _tasks/features-v3.8.42/gaps/DISCOVERY_TOOL_DESIGN.md.
VNC_ROUTE_PREFIX, // #7892: /api/vnc-session/* spawns Docker containers via child_process.spawn (src/lib/vncSession/service.ts) — RCE-via-tunnel surface (Hard Rules #15 + #17), same CVE class (GHSA-fhh6-4qxv-rpqj).
"/api/acp/agents", // ACP custom-agent registry: POST registers a client-chosen `binary`; GET / POST {action:"refresh"} runs detectInstalledAgents() -> execFileSync(probe.command, probe.args, { shell }) transitively (src/lib/acp/registry.ts) — RCE-via-tunnel surface (Hard Rules #15 + #17, #7948)
"/api/providers/cursor/agent-availability", // credential-free dashboard-nudge check: spawns `cursor-agent status --format json` via checkCursorAgentAvailability()/getCachedCursorAgentAvailability() (src/lib/cursor/renewal.ts) — RCE-via-tunnel surface (Hard Rules #15 + #17). Narrow-scoped like /login and /refresh-cursor, not the whole /api/providers/ tree. Placed under /api/providers/ rather than /api/oauth/ because /api/oauth/ is PUBLIC-classified and never reaches this LOCAL_ONLY gate.
];
/**

View File

@@ -50,6 +50,7 @@ export const SPAWN_CAPABLE_PREFIXES: ReadonlyArray<string> = [
export const SPAWN_CAPABLE_PATTERNS: ReadonlyArray<RegExp> = [
/^\/api\/providers\/[^/]+\/login\/?$/, // pre-existing gap: in LOCAL_ONLY_API_PATTERNS today but never in a spawn-capable deny-list
/^\/api\/providers\/[^/]+\/refresh-cursor\/?$/, // spawns cursor-agent via renewal.ts (Hard Rules #15 + #17)
/^\/api\/providers\/cursor\/agent-availability\/?$/, // static path (no dynamic segment), but kept in this array alongside its /api/providers/ siblings rather than the flat SPAWN_CAPABLE_PREFIXES array — spawns cursor-agent status via checkCursorAgentAvailability()/getCachedCursorAgentAvailability() (Hard Rules #15 + #17)
];
/**

View File

@@ -0,0 +1,61 @@
/**
* GET /api/providers/cursor/agent-availability — "authenticated -> true" case.
* Split from tests/unit/cursor-agent-availability-route.test.ts (its own
* process, so its own fresh getCachedCursorAgentAvailability() module cache —
* see that file's header comment for why the true/false cases can't share a
* process).
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
process.env.NODE_ENV = "test";
const TEST_DATA_DIR = fs.mkdtempSync(
path.join(os.tmpdir(), "omniroute-agent-availability-route-auth-")
);
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const { GET } = await import("../../src/app/api/providers/cursor/agent-availability/route.ts");
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
const FAKE_CURSOR_AGENT_SCRIPT = `#!/usr/bin/env node
const args = process.argv.slice(2);
if (args[0] === "status") {
process.stdout.write(JSON.stringify({ status: "authenticated", isAuthenticated: true }));
}
`;
const originalHome = process.env.HOME;
const originalUserProfile = process.env.USERPROFILE;
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-agent-availability-home-auth-"));
process.env.HOME = tmpHome;
process.env.USERPROFILE = tmpHome;
const binaryPath = path.join(tmpHome, ".local", "bin", "cursor-agent");
fs.mkdirSync(path.dirname(binaryPath), { recursive: true });
fs.writeFileSync(binaryPath, FAKE_CURSOR_AGENT_SCRIPT, { mode: 0o755 });
fs.chmodSync(binaryPath, 0o755);
test.after(() => {
process.env.HOME = originalHome;
if (originalUserProfile !== undefined) process.env.USERPROFILE = originalUserProfile;
else delete process.env.USERPROFILE;
fs.rmSync(tmpHome, { recursive: true, force: true });
});
test("returns {cursorAgentAvailable: true} and ONLY that field when cursor-agent is authenticated", async () => {
const res = await GET();
const body = (await res.json()) as Record<string, unknown>;
assert.equal(res.status, 200);
assert.deepEqual(Object.keys(body), ["cursorAgentAvailable"]);
assert.equal(body.cursorAgentAvailable, true);
assert.equal(body.accessToken, undefined);
assert.equal(body.machineId, undefined);
});

View File

@@ -0,0 +1,116 @@
/**
* GET /api/providers/cursor/agent-availability (Cursor renewal plan, Task 5).
*
* Real fake-cursor-agent-binary + HOME-override technique (see
* tests/unit/cursor-renewal.test.ts) to drive getCachedCursorAgentAvailability()
* for real — no mocking, same rationale as every other Cursor test file in
* this plan (no DI seam, no mock.module() support in this harness).
*
* getCachedCursorAgentAvailability() has a module-level 5-minute TTL cache
* with no exported reset hook, so this file only exercises ONE truth value
* through the live route (the "unauthenticated" default state a fresh test
* fixture naturally has) — a second call within the same process would
* silently replay the FIRST call's cached result regardless of a changed
* fixture, which would look like a passing assertion for the wrong reason.
* The "authenticated -> true" mapping is verified in a separate file
* (tests/unit/cursor-agent-availability-route-authenticated.test.ts, its own
* process, so its own fresh cache) — the TTL cache's own behavior (reuse
* within the window, fresh spawn after expiry) is covered directly in
* tests/unit/cursor-renewal.test.ts.
*
* DATA_DIR is overridden to a temp dir BEFORE any import below, since loading
* src/server/authz/policies/management.ts transitively touches the real DB
* singleton at import time.
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
process.env.NODE_ENV = "test";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-agent-availability-route-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const { GET } = await import("../../src/app/api/providers/cursor/agent-availability/route.ts");
const { managementPolicy } = await import("../../src/server/authz/policies/management.ts");
const FAKE_CURSOR_AGENT_SCRIPT = `#!/usr/bin/env node
const args = process.argv.slice(2);
if (args[0] === "status") {
const mode = process.env.FAKE_CURSOR_AGENT_STATUS_MODE || "unauthenticated";
if (mode === "authenticated") {
process.stdout.write(JSON.stringify({ status: "authenticated", isAuthenticated: true }));
} else {
process.stdout.write(JSON.stringify({ status: "unauthenticated", isAuthenticated: false }));
}
}
`;
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
function writeFakeCursorAgentBinary(destPath: string): void {
fs.mkdirSync(path.dirname(destPath), { recursive: true });
fs.writeFileSync(destPath, FAKE_CURSOR_AGENT_SCRIPT, { mode: 0o755 });
fs.chmodSync(destPath, 0o755);
}
const originalHome = process.env.HOME;
const originalUserProfile = process.env.USERPROFILE;
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-agent-availability-home-"));
process.env.HOME = tmpHome;
process.env.USERPROFILE = tmpHome;
process.env.FAKE_CURSOR_AGENT_STATUS_MODE = "unauthenticated";
writeFakeCursorAgentBinary(path.join(tmpHome, ".local", "bin", "cursor-agent"));
test.after(() => {
process.env.HOME = originalHome;
if (originalUserProfile !== undefined) process.env.USERPROFILE = originalUserProfile;
else delete process.env.USERPROFILE;
delete process.env.FAKE_CURSOR_AGENT_STATUS_MODE;
fs.rmSync(tmpHome, { recursive: true, force: true });
});
test("returns {cursorAgentAvailable: false} and ONLY that field when cursor-agent is unauthenticated", async () => {
const res = await GET();
const body = (await res.json()) as Record<string, unknown>;
assert.equal(res.status, 200);
assert.deepEqual(Object.keys(body), ["cursorAgentAvailable"]);
assert.equal(body.cursorAgentAvailable, false);
assert.equal(body.accessToken, undefined);
assert.equal(body.machineId, undefined);
});
// Loopback enforcement happens unconditionally before any auth check (Hard
// Rules #15 + #17): a non-loopback caller with NO credentials at all must
// still be rejected by the managementPolicy pipeline itself — never by an
// in-route check (this route intentionally has none; see route.ts's own
// comment on why).
test("a non-loopback, unauthenticated request is rejected by managementPolicy (403 LOCAL_ONLY), not by the route", async () => {
const requestPath = "/api/providers/cursor/agent-availability";
const outcome = await managementPolicy.evaluate({
request: {
method: "GET",
headers: new Headers(),
url: `https://dashboard.example${requestPath}`,
nextUrl: { pathname: requestPath },
},
classification: {
routeClass: "MANAGEMENT",
normalizedPath: requestPath,
reason: "management_api",
},
requestId: "req_cursor_agent_availability_test",
} as unknown as Parameters<typeof managementPolicy.evaluate>[0]);
assert.equal(outcome.allow, false);
if (!outcome.allow) {
assert.equal(outcome.status, 403);
assert.equal(outcome.code, "LOCAL_ONLY");
}
});

View File

@@ -29,6 +29,7 @@ import path from "node:path";
import {
runCursorAgentNudge,
checkCursorAgentAvailability,
getCachedCursorAgentAvailability,
renewCursorConnection,
buildCursorRenewedUpdate,
runCursorRenewalExclusive,
@@ -262,6 +263,63 @@ describe("checkCursorAgentAvailability", () => {
});
});
describe("getCachedCursorAgentAvailability (Task 5 Step 1 — 5-minute TTL wrapper for UI callers)", () => {
const ORIGINAL_HOME = process.env.HOME;
const ORIGINAL_USERPROFILE = process.env.USERPROFILE;
const CACHE_TTL_MS = 5 * 60 * 1000; // mirrors CURSOR_AGENT_AVAILABILITY_CACHE_TTL_MS in renewal.ts
let tmpHome: string;
let logPath: string;
beforeEach(() => {
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-cursor-avail-cache-"));
process.env.HOME = tmpHome;
process.env.USERPROFILE = tmpHome;
writeFakeCursorAgentBinary(path.join(tmpHome, ".local", "bin", "cursor-agent"));
logPath = path.join(tmpHome, "log.jsonl");
process.env.FAKE_CURSOR_AGENT_LOG = logPath;
process.env.FAKE_CURSOR_AGENT_STATUS_MODE = "authenticated";
});
afterEach(() => {
process.env.HOME = ORIGINAL_HOME;
if (ORIGINAL_USERPROFILE !== undefined) process.env.USERPROFILE = ORIGINAL_USERPROFILE;
else delete process.env.USERPROFILE;
clearFakeCursorAgentEnv();
fs.rmSync(tmpHome, { recursive: true, force: true });
});
// A single test, one continuous mocked timeline: getCachedCursorAgentAvailability()'s
// module-level cache has no exported reset hook and persists for the life of the
// process, so two separate `it()` blocks each assuming a "fresh" cache would be
// order-dependent (a later test could silently inherit an earlier test's still-valid
// cache entry, since node:test's per-test mock-timer teardown restores the REAL clock
// between tests, not the fake one — the leftover `expiresAt` would still be far in
// that real future). Keeping both assertions on one uninterrupted fake clock avoids that.
it("reuses the cached result within the TTL window, then spawns exactly once more after it expires", async (t) => {
t.mock.timers.enable({ apis: ["Date"] });
const first = await getCachedCursorAgentAvailability();
assert.equal(readLoggedInvocations(logPath).length, 1, "the first call must spawn");
t.mock.timers.tick(CACHE_TTL_MS - 1000); // still inside the window
const second = await getCachedCursorAgentAvailability();
assert.deepEqual(first, second);
assert.equal(
readLoggedInvocations(logPath).length,
1,
"still within the TTL — no second spawn"
);
t.mock.timers.tick(2000); // now past the TTL (cumulative: TTL + 1000ms)
await getCachedCursorAgentAvailability();
assert.equal(
readLoggedInvocations(logPath).length,
2,
"expiry must trigger exactly one fresh spawn"
);
});
});
describe("renewCursorConnection", () => {
const ORIGINAL_HOME = process.env.HOME;
const ORIGINAL_USERPROFILE = process.env.USERPROFILE;

View File

@@ -0,0 +1,49 @@
/**
* Security regression (Cursor renewal plan, Task 5): GET
* /api/providers/cursor/agent-availability is a credential-free check for the
* dashboard's install-nudge banner, but it still spawns `cursor-agent status
* --format json` (via checkCursorAgentAvailability()/
* getCachedCursorAgentAvailability()) — so it MUST be LOCAL_ONLY, same as
* every other spawn-capable route (Hard Rules #15 + #17).
*
* Unlike Task 4's refresh-cursor route, this one is a STATIC path (no dynamic
* `[id]` segment), so it's classified via the flat LOCAL_ONLY_API_PREFIXES
* list, not a regex in LOCAL_ONLY_API_PATTERNS. It was originally scoped
* under `/api/oauth/cursor/agent-availability` during planning, then
* relocated under `/api/providers/` because `/api/oauth/` is PUBLIC-classified
* (see classify.ts) and never reaches the LOCAL_ONLY gate at all — see
* docs/security/ROUTE_GUARD_TIERS.md. The classifyRoute assertion below pins
* that decision as a regression guard against ever moving this back.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { isLocalOnlyPath } from "../../src/server/authz/routeGuard.ts";
import { classifyRoute } from "../../src/server/authz/classify.ts";
test("/api/providers/cursor/agent-availability is LOCAL_ONLY (spawns cursor-agent status)", () => {
assert.equal(isLocalOnlyPath("/api/providers/cursor/agent-availability"), true);
});
test("/api/providers/cursor/agent-availability with a trailing slash is LOCAL_ONLY", () => {
assert.equal(isLocalOnlyPath("/api/providers/cursor/agent-availability/"), true);
});
test("classifyRoute resolves this path to MANAGEMENT, never PUBLIC (regression guard against moving it under /api/oauth/)", () => {
const classification = classifyRoute("/api/providers/cursor/agent-availability", "GET");
assert.equal(classification.routeClass, "MANAGEMENT");
});
test("does not over-match unrelated /api/providers paths", () => {
// LOCAL_ONLY_API_PREFIXES entries are matched via plain startsWith (see
// isLocalOnlyPath) — like every other exact-path-style sibling entry in
// that array (e.g. /api/system/version, /api/oauth/cursor/auto-import,
// /api/acp/agents), this is a bare path with no trailing slash, so it is
// NOT segment-boundary-anchored the way the regex-based /login and
// /refresh-cursor entries in LOCAL_ONLY_API_PATTERNS are (see
// tests/unit/route-guard-cursor-refresh.test.ts). Only paths that don't
// share the prefix at all are meaningful negative cases here.
assert.equal(isLocalOnlyPath("/api/providers"), false);
assert.equal(isLocalOnlyPath("/api/providers/"), false);
assert.equal(isLocalOnlyPath("/api/providers/cursor"), false);
assert.equal(isLocalOnlyPath("/api/providers/abc123/refresh"), false);
});