mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-13 10:43:43 +03:00
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:
committed by
diegosouzapw
parent
df3b691809
commit
21c68ec86e
@@ -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]
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
31
src/app/api/providers/cursor/agent-availability/route.ts
Normal file
31
src/app/api/providers/cursor/agent-availability/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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.
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
116
tests/unit/cursor-agent-availability-route.test.ts
Normal file
116
tests/unit/cursor-agent-availability-route.test.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
49
tests/unit/route-guard-cursor-agent-availability.test.ts
Normal file
49
tests/unit/route-guard-cursor-agent-availability.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user