feat(cursor): adds local-only manual refresh route

Adds POST /api/providers/[id]/refresh-cursor, a dedicated
loopback-only route that calls the renewal orchestrator on demand
for a single Cursor connection, bounded by a 30s per-connection
cooldown. Classifies the new route in LOCAL_ONLY_API_PATTERNS and
closes the manage-scope-bypass gap for dynamic-segment spawn-capable
routes under /api/providers/ via a new SPAWN_CAPABLE_PATTERNS /
SPAWN_CAPABLE_PATTERN_ANCESTORS mechanism, which also retroactively
covers the pre-existing /login route. The existing shared
/api/providers/[id]/refresh route is untouched and stays
remote-reachable for every other provider.
This commit is contained in:
Will Gordon
2026-07-31 10:42:01 -04:00
committed by diegosouzapw
parent 71f1804e99
commit df3b691809
8 changed files with 677 additions and 39 deletions

View File

@@ -39,22 +39,23 @@ spawn-capable route: a leaked token over a tunnel still can't reach the spawn.
`check-route-guard-membership` gate enumerates every `route.ts` under the
spawn-capable prefixes and fails CI if any is not classified local-only.
| Prefix / pattern | Why it's local-only | Manage-scope bypassable? |
| ----------------------------------- | ---------------------------------------------------------------------------------------- | ----------------------------- |
| `/api/mcp/` | MCP server — spawns stdio bridges + SSE handlers | **Yes** (only one) |
| `/api/cli-tools/runtime/` | CLI tool runtime — executes arbitrary plugin code | No — spawn-capable |
| `/api/services/` | Embedded services (9router/CLIProxy) — `npm install` + spawn | No — spawn-capable |
| `/dashboard/providers/services/` | Reverse proxy to embedded-service UIs | No |
| `/api/copilot/` | Unauthenticated LLM driver — CLI-only by default | Operator opt-in: manage/admin |
| `/api/tools/agent-bridge/` | AgentBridge — spawns MITM server + DNS edits | No — spawn-capable |
| `/api/tools/traffic-inspector/` | Traffic Inspector — http-proxy listener + system proxy | No — spawn-capable |
| `/api/plugins/`, `/api/plugins` | Plugins — load/execute via `worker_threads` + `child_process` | No — spawn-capable |
| `/api/system/version` | Auto-update (POST only; GET/HEAD/OPTIONS exempt) — spawns `git checkout` + `npm install` | No |
| `/api/db-backups/exportAll` | Spawns `tar` for the export archive | No |
| `/api/local/` | 1-click local launchers (Redis today) — spawns podman/docker | No — spawn-capable |
| `/api/headroom/start`, `/stop` | Headroom proxy lifecycle — spawns python CLI / signals PID | No — spawn-capable |
| `/api/oauth/cursor/auto-import` | `execFile("which", ["cursor"])` before importing creds | No |
| `/api/providers/{id}/login` (regex) | Launches a headful Playwright Chromium for web-cookie login | No |
| Prefix / pattern | Why it's local-only | Manage-scope bypassable? |
| -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- |
| `/api/mcp/` | MCP server — spawns stdio bridges + SSE handlers | **Yes** (only one) |
| `/api/cli-tools/runtime/` | CLI tool runtime — executes arbitrary plugin code | No — spawn-capable |
| `/api/services/` | Embedded services (9router/CLIProxy) — `npm install` + spawn | No — spawn-capable |
| `/dashboard/providers/services/` | Reverse proxy to embedded-service UIs | No |
| `/api/copilot/` | Unauthenticated LLM driver — CLI-only by default | Operator opt-in: manage/admin |
| `/api/tools/agent-bridge/` | AgentBridge — spawns MITM server + DNS edits | No — spawn-capable |
| `/api/tools/traffic-inspector/` | Traffic Inspector — http-proxy listener + system proxy | No — spawn-capable |
| `/api/plugins/`, `/api/plugins` | Plugins — load/execute via `worker_threads` + `child_process` | No — spawn-capable |
| `/api/system/version` | Auto-update (POST only; GET/HEAD/OPTIONS exempt) — spawns `git checkout` + `npm install` | No |
| `/api/db-backups/exportAll` | Spawns `tar` for the export archive | No |
| `/api/local/` | 1-click local launchers (Redis today) — spawns podman/docker | No — spawn-capable |
| `/api/headroom/start`, `/stop` | Headroom proxy lifecycle — spawns python CLI / signals PID | No — spawn-capable |
| `/api/oauth/cursor/auto-import` | `execFile("which", ["cursor"])` before importing creds | No |
| `/api/providers/{id}/login` (regex) | Launches a headful Playwright Chromium for web-cookie login | No |
| `/api/providers/{id}/refresh-cursor` (regex) | Manual Cursor session renewal — nudges `cursor-agent` (`--list-models`/`status` via `src/lib/cursor/renewal.ts`); the rest of `/api/providers/`, including the generic `/refresh`, intentionally stays remote-reachable | No — spawn-capable |
**Response on violation:** `403 LOCAL_ONLY`
@@ -84,15 +85,15 @@ ever be added), and it is deliberately excluded from
carve-out exactly as before; `mcp:connect` is a lower-privilege alternative
for remote MCP-only callers who should not need broad management access.
| Request | Path | Result |
| ------------------------------------------------- | -------------------------- | ------------------- |
| Non-loopback, no Bearer | `/api/mcp/*` | 403 LOCAL_ONLY |
| Non-loopback, Bearer with `manage` scope | `/api/mcp/*` | Allow |
| Non-loopback, Bearer with `mcp:connect` scope | `/api/mcp/*` | Allow |
| Non-loopback, Bearer without `manage`/`mcp:connect` | `/api/mcp/*` | 403 LOCAL_ONLY |
| Non-loopback, Bearer with `mcp:connect` scope | `/api/cli-tools/runtime/*` | 403 LOCAL_ONLY |
| Non-loopback, Bearer with `manage` scope | `/api/cli-tools/runtime/*` | 403 LOCAL_ONLY |
| Loopback, any/no Bearer | any LOCAL_ONLY | Allow (gate passes) |
| Request | Path | Result |
| --------------------------------------------------- | -------------------------- | ------------------- |
| Non-loopback, no Bearer | `/api/mcp/*` | 403 LOCAL_ONLY |
| Non-loopback, Bearer with `manage` scope | `/api/mcp/*` | Allow |
| Non-loopback, Bearer with `mcp:connect` scope | `/api/mcp/*` | Allow |
| Non-loopback, Bearer without `manage`/`mcp:connect` | `/api/mcp/*` | 403 LOCAL_ONLY |
| Non-loopback, Bearer with `mcp:connect` scope | `/api/cli-tools/runtime/*` | 403 LOCAL_ONLY |
| Non-loopback, Bearer with `manage` scope | `/api/cli-tools/runtime/*` | 403 LOCAL_ONLY |
| Loopback, any/no Bearer | any LOCAL_ONLY | Allow (gate passes) |
#### Operator guidance & auditing
@@ -110,7 +111,14 @@ operator responsibilities remain:
only with a `manage`-scoped API key. The `SPAWN_CAPABLE_PREFIXES` can never be
added to the bypass list — the zod schema rejects them and
`isLocalOnlyBypassableByManageScope` denies them at runtime (defence-in-depth),
which is what the dashboard means by "cannot be made bypassable".
which is what the dashboard means by "cannot be made bypassable". Dynamic-segment
and static-path spawn-capable routes under `/api/providers/` (e.g. `/login`,
`/refresh-cursor`) are covered by the regex-based `SPAWN_CAPABLE_PATTERNS` /
`SPAWN_CAPABLE_PATTERN_ANCESTORS` companion in
`src/shared/constants/spawnCapablePrefixes.ts`, not by the flat
`SPAWN_CAPABLE_PREFIXES` array — the flat array would have to cover the
entire `/api/providers/` prefix to catch them, over-broadening a route tree
remote dashboards legitimately use for provider CRUD.
**Auditing access** — to verify nothing off-host is reaching these routes:

View File

@@ -0,0 +1,124 @@
import { NextResponse } from "next/server";
import { getCachedProviderConnectionById } from "@/lib/localDb";
import { updateProviderConnection } from "@/lib/db/providers";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import {
renewCursorConnection,
buildCursorRenewedUpdate,
runCursorRenewalExclusive,
} from "@/lib/cursor/renewal";
interface CursorConnectionLike {
id: string;
provider?: string;
accessToken?: string;
expiresAt?: string | null;
providerSpecificData?: Record<string, unknown> | null;
}
const MANUAL_REFRESH_COOLDOWN_MS = 30_000;
/**
* Per-connection cooldown for rapid sequential (non-concurrent) manual
* "Refresh" clicks. Bounds repeated real authenticated `--list-models` calls
* to Cursor's API from a user mashing the button — independent of, and much
* shorter than, the sweep's `isInRefreshBackoff()` circuit (left untouched).
* The in-flight-spawn lock (Task 2 Step 3) already caps CONCURRENT spawns;
* this caps repeated SEQUENTIAL ones, which that lock does not throttle.
*/
const lastManualRefreshAttemptAt = new Map<string, number>();
/**
* POST /api/providers/[id]/refresh-cursor
* Manually trigger a Cursor session renewal attempt (nudge `cursor-agent`,
* re-scrape IDE/agent credential sources). Dedicated route because Cursor has
* no refresh_token by design — the generic `/api/providers/[id]/refresh`
* route always silently 502s for Cursor connections today.
*
* 🔒 LOCAL_ONLY — classified in `LOCAL_ONLY_API_PATTERNS`
* (`src/server/authz/routeGuard.ts`) because `renewCursorConnection()` spawns
* `cursor-agent` as a child process (Hard Rules #15 + #17). Unlike this
* route, the rest of `/api/providers/` (including the generic `/refresh`)
* intentionally remains remote-reachable.
*/
export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const connection = (await getCachedProviderConnectionById(id)) as CursorConnectionLike | null;
if (!connection) {
return NextResponse.json({ error: "Connection not found" }, { status: 404 });
}
if (connection.provider !== "cursor") {
return NextResponse.json(
{ error: "This route only supports Cursor connections" },
{ status: 400 }
);
}
const lastAttempt = lastManualRefreshAttemptAt.get(connection.id) ?? 0;
const elapsedMs = Date.now() - lastAttempt;
if (elapsedMs < MANUAL_REFRESH_COOLDOWN_MS) {
const retryAfterMs = MANUAL_REFRESH_COOLDOWN_MS - elapsedMs;
return NextResponse.json(
{
error: "Refresh already attempted recently — please wait before retrying.",
retryAfterMs,
},
{
status: 429,
headers: { "Retry-After": String(Math.ceil(retryAfterMs / 1000)) },
}
);
}
// Set immediately before invoking renewCursorConnection() — regardless of
// outcome — so rapid repeated clicks are throttled either way.
lastManualRefreshAttemptAt.set(connection.id, Date.now());
return await runCursorRenewalExclusive(connection.id, async () => {
const result = await renewCursorConnection({
accessToken: connection.accessToken ?? "",
machineId: connection.providerSpecificData?.machineId as string | null | undefined,
});
if (result.status === "renewed") {
const now = new Date().toISOString();
const update = buildCursorRenewedUpdate(connection, result, now);
await updateProviderConnection(connection.id, update);
return NextResponse.json({
success: true,
connectionId: connection.id,
provider: "cursor",
expiresAt: update.expiresAt as string,
refreshedAt: now,
});
}
if (result.status === "unchanged") {
return NextResponse.json({
success: true,
unchanged: true,
connectionId: connection.id,
provider: "cursor",
expiresAt: connection.expiresAt ?? null,
refreshedAt: new Date().toISOString(),
message: "Cursor session is already current — no newer token found on this host.",
});
}
return NextResponse.json(
{ error: "Token refresh failed — provider returned no new token", details: result.error },
{ status: 502 }
);
});
} catch (error) {
return NextResponse.json(
{
error: "Token refresh failed",
details: sanitizeErrorMessage(error instanceof Error ? error.message : String(error)),
},
{ status: 500 }
);
}
}

View File

@@ -22,7 +22,10 @@
*/
import { getAuthzBypassSnapshot } from "@/lib/config/runtimeSettings";
import { SPAWN_CAPABLE_PREFIXES } from "@/shared/constants/spawnCapablePrefixes";
import {
SPAWN_CAPABLE_PREFIXES,
SPAWN_CAPABLE_PATTERNS,
} from "@/shared/constants/spawnCapablePrefixes";
import { VNC_ROUTE_PREFIX } from "@/lib/vncSession/manifest";
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
@@ -62,24 +65,37 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray<string> = [
* parameter, so a flat prefix in `LOCAL_ONLY_API_PREFIXES` cannot target them
* without over-broadening (e.g. locking the entire `/api/providers/` subtree,
* which remote dashboards legitimately use for provider CRUD). These are matched
* by regex instead.
* by regex instead against the concrete resolved path — which is already
* `request.nextUrl.pathname` (see `runAuthzPipeline`/`classifyRoute`), the
* SAME string Next.js's own file-based router uses to resolve the `[id]`
* dynamic segment, so there is no decode/normalization mismatch between what
* this regex sees and what actually gets dispatched to the route handler.
*
* - `POST /api/providers/{id}/login` launches a headful Playwright Chromium
* (a child process) to drive a web-cookie login. Loopback enforcement must
* happen unconditionally before any auth check (Hard Rules #15 + #17), so a
* leaked JWT via tunnel cannot trigger a browser spawn.
* - `POST /api/providers/{id}/refresh-cursor` nudges `cursor-agent`
* (`--list-models`/`status`, via `src/lib/cursor/renewal.ts`) as part of
* a manual Cursor session renewal attempt — the same RCE-via-tunnel
* surface (Hard Rules #15 + #17). The rest of `/api/providers/`,
* including the generic `/refresh` route, intentionally stays
* remote-reachable — only this Cursor-specific spawn-capable path is
* gated, matching the `/login` precedent's narrow-scoping rationale.
*/
export const LOCAL_ONLY_API_PATTERNS: ReadonlyArray<RegExp> = [
/^\/api\/providers\/[^/]+\/login\/?$/,
/^\/api\/providers\/[^/]+\/refresh-cursor\/?$/,
];
// `SPAWN_CAPABLE_PREFIXES` (the spawn-capable deny-list) now lives in the
// server-free leaf module `@/shared/constants/spawnCapablePrefixes` so that
// client-reachable validation schemas can import it without pulling this module's
// server runtime (runtimeSettings → localDb → ioredis) into the browser bundle.
// `SPAWN_CAPABLE_PREFIXES` / `SPAWN_CAPABLE_PATTERNS` (the spawn-capable
// deny-lists) now live in the server-free leaf module
// `@/shared/constants/spawnCapablePrefixes` so that client-reachable
// validation schemas can import them without pulling this module's server
// runtime (runtimeSettings → localDb → ioredis) into the browser bundle.
// Imported above for the runtime check in `isLocalOnlyBypassableByManageScope`;
// re-exported here so existing `@/server/authz/routeGuard` importers keep working.
export { SPAWN_CAPABLE_PREFIXES };
export { SPAWN_CAPABLE_PREFIXES, SPAWN_CAPABLE_PATTERNS };
/**
* Compile-time default of the manage-scope bypass list. Kept as an exported
@@ -174,9 +190,7 @@ export function isPrivateLanHost(hostHeader: string | null): boolean {
* triggers the auto-update flow (spawns git checkout + npm install + pm2).
* Hard Rules #15/#17 still apply to POST.
*/
export const LOCAL_ONLY_API_GET_EXEMPTIONS: ReadonlySet<string> = new Set([
"/api/system/version",
]);
export const LOCAL_ONLY_API_GET_EXEMPTIONS: ReadonlySet<string> = new Set(["/api/system/version"]);
/** Safe HTTP methods that can be exempted for read-only paths. */
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
@@ -221,6 +235,13 @@ export function isLocalOnlyPath(path: string, method?: string): boolean {
* O(1) (no I/O, no async). Hot-reload SLA: <50 ms — satisfied structurally.
*/
export function isLocalOnlyBypassableByManageScope(path: string): boolean {
// Precise, unconditional early-deny for regex-matched spawn-capable routes
// (e.g. /api/providers/{id}/login, /api/providers/{id}/refresh-cursor).
// Unlike the flat-prefix defence-in-depth check below, this has the
// concrete resolved `path` already, so it's an exact match — no
// reachability heuristics needed.
if (SPAWN_CAPABLE_PATTERNS.some((re) => re.test(path))) return false;
const snapshot = getAuthzBypassSnapshot();
if (!snapshot.enabled) return false;
return snapshot.prefixes.some((p) => {

View File

@@ -36,3 +36,31 @@ export const SPAWN_CAPABLE_PREFIXES: ReadonlyArray<string> = [
"/api/headroom/stop", // kills tracked PID — must never be bypassable (Hard Rules #15 + #17)
"/api/vnc-session", // #7892: spawns Docker containers via child_process.spawn (src/lib/vncSession/service.ts) — must never be whitelistable via manage-scope bypass (Hard Rules #15 + #17)
];
/**
* Regex-matched companion to `SPAWN_CAPABLE_PREFIXES`, for spawn-capable
* routes whose spawn-capable segment sits AFTER a dynamic path parameter
* (e.g. `/api/providers/{id}/refresh-cursor`) — a flat prefix would either
* miss them entirely or require over-broadening the shared `/api/providers/`
* prefix (used for legitimate remote provider CRUD). Mirrors the
* `LOCAL_ONLY_API_PREFIXES`/`LOCAL_ONLY_API_PATTERNS` split already
* established in `routeGuard.ts` for this exact shape. Checked against a
* CONCRETE resolved request path — an exact regex match, no approximation.
*/
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)
];
/**
* Companion to `SPAWN_CAPABLE_PATTERNS`, used ONLY by the zod-level candidate
* bypass-prefix check (`settingsSchemas.ts`), which validates a candidate
* BYPASS PREFIX STRING (not a concrete path) at `PATCH /api/settings` time —
* general prefix-vs-regex reachability is undecidable, so this conservatively
* treats the shared literal ancestor of the dynamic/static-segment patterns
* as off-limits. Intentionally coarser than `SPAWN_CAPABLE_PATTERNS`'s exact
* per-route match, but costs nothing security-wise: the runtime check in
* `isLocalOnlyBypassableByManageScope` (Layer 2) is the actual enforcement
* boundary and stays exact. `SPAWN_CAPABLE_PREFIXES` itself is untouched.
*/
export const SPAWN_CAPABLE_PATTERN_ANCESTORS: ReadonlyArray<string> = ["/api/providers/"];

View File

@@ -15,7 +15,10 @@ import { RESPONSES_PREVIOUS_RESPONSE_ID_MODES } from "@/shared/constants/respons
// Import from the server-free constants leaf, NOT from `@/server/authz/routeGuard`:
// this schema is reachable from client components (dashboard onboarding wizard), and
// routeGuard drags in server runtime (→ ioredis) that breaks the client/CLI build.
import { SPAWN_CAPABLE_PREFIXES } from "@/shared/constants/spawnCapablePrefixes";
import {
SPAWN_CAPABLE_PREFIXES,
SPAWN_CAPABLE_PATTERN_ANCESTORS,
} from "@/shared/constants/spawnCapablePrefixes";
const signatureCacheModeValues = ["enabled", "bypass", "bypass-strict"] as const;
@@ -135,7 +138,10 @@ export const updateSettingsSchema = z.object({
showProviderTopologyOnHome: z.boolean().optional(),
localOnlyManageScopeBypassEnabled: z.boolean().optional(),
// Layer 1 of the spawn-capable guard (Hard Rules #15/#17): reject any bypass
// prefix that reaches a SPAWN_CAPABLE_PREFIXES path at PATCH time, with the
// prefix that reaches a SPAWN_CAPABLE_PREFIXES path, or a
// SPAWN_CAPABLE_PATTERN_ANCESTORS ancestor (e.g. /api/providers/, the
// shared ancestor of the dynamic-segment routes in SPAWN_CAPABLE_PATTERNS
// such as /login and /refresh-cursor), at PATCH time, with the
// BYPASS_PREFIX_NOT_ALLOWED code the settings route handler translates.
// Layer 2 (isLocalOnlyBypassableByManageScope) still refuses spawn paths at
// runtime even if a malformed DB row claims otherwise. This refine was in the
@@ -149,7 +155,10 @@ export const updateSettingsSchema = z.object({
.refine(
(prefix) => {
const normalized = prefix.endsWith("/") ? prefix : `${prefix}/`;
return !SPAWN_CAPABLE_PREFIXES.some((sp) => normalized.startsWith(sp));
return (
!SPAWN_CAPABLE_PREFIXES.some((sp) => normalized.startsWith(sp)) &&
!SPAWN_CAPABLE_PATTERN_ANCESTORS.some((sp) => normalized.startsWith(sp))
);
},
{
message:

View File

@@ -0,0 +1,310 @@
/**
* POST /api/providers/[id]/refresh-cursor (Cursor renewal plan, Task 4).
*
* Direct route.ts invocation, matching this codebase's existing precedent
* for testing App Router handlers without a running server (e.g.
* tests/unit/dahl-tokens-route.test.ts, tests/unit/agent-bridge-dns-params-7271.test.ts):
* import the exported POST function and call it with a real Request and
* `{ params: Promise.resolve({ id }) }`.
*
* Real DB (temp DATA_DIR, same convention as tests/unit/token-health-check-cursor.test.ts)
* and the same real fake-cursor-agent-binary + HOME-override technique from
* tests/unit/cursor-renewal.test.ts — renewCursorConnection() has no deps
* override at this call site either, so its dependencies are driven for
* real, never mocked.
*/
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-refresh-cursor-route-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const { POST } = await import("../../src/app/api/providers/[id]/refresh-cursor/route.ts");
test.after(async () => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
function getId(connection: { id?: unknown }): string {
assert.equal(typeof connection.id, "string");
return connection.id as string;
}
function makeRequest(): Request {
return new Request("http://localhost/api/providers/x/refresh-cursor", { method: "POST" });
}
function callRoute(id: string) {
return POST(makeRequest(), { params: Promise.resolve({ id }) });
}
// ---- Real fake cursor-agent binary + IDE/agent fixtures (mirrors
// tests/unit/cursor-renewal.test.ts / tests/unit/token-health-check-cursor.test.ts) ----
const FAKE_CURSOR_AGENT_SCRIPT = `#!/usr/bin/env node
const fs = require("fs");
const args = process.argv.slice(2);
if (process.env.FAKE_CURSOR_AGENT_LOG) {
fs.appendFileSync(process.env.FAKE_CURSOR_AGENT_LOG, JSON.stringify(args) + "\\n");
}
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 }));
}
}
`;
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);
}
function readLoggedInvocations(logPath: string): string[][] {
if (!fs.existsSync(logPath)) return [];
return fs
.readFileSync(logPath, "utf-8")
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line));
}
interface CursorEnv {
tmpHome: string;
logPath: string;
writeIdeToken(accessToken: string, machineId?: string): Promise<void>;
cleanup(): void;
}
async function withCursorEnv<T>(fn: (env: CursorEnv) => Promise<T>): Promise<T> {
const originalHome = process.env.HOME;
const originalUserProfile = process.env.USERPROFILE;
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
Object.defineProperty(process, "platform", { value: "darwin", configurable: true });
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-refresh-cursor-route-env-"));
process.env.HOME = tmpHome;
process.env.USERPROFILE = tmpHome;
const logPath = path.join(tmpHome, "log.jsonl");
process.env.FAKE_CURSOR_AGENT_LOG = logPath;
process.env.FAKE_CURSOR_AGENT_STATUS_MODE = "unauthenticated";
writeFakeCursorAgentBinary(path.join(tmpHome, ".local", "bin", "cursor-agent"));
const env: CursorEnv = {
tmpHome,
logPath,
async writeIdeToken(accessToken, machineId) {
const { openDatabaseAsync } = await import("../../src/lib/db/adapters/driverFactory.ts");
const dbPath = path.join(
tmpHome,
"Library/Application Support/Cursor/User/globalStorage/state.vscdb"
);
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
const seed = await openDatabaseAsync(dbPath);
seed.exec("CREATE TABLE itemTable (key TEXT PRIMARY KEY, value TEXT)");
seed
.prepare("INSERT INTO itemTable (key, value) VALUES (?, ?)")
.run("cursorAuth/accessToken", accessToken);
if (machineId) {
seed
.prepare("INSERT INTO itemTable (key, value) VALUES (?, ?)")
.run("storage.serviceMachineId", machineId);
}
seed.close();
},
cleanup() {
if (originalPlatformDescriptor) {
Object.defineProperty(process, "platform", originalPlatformDescriptor);
}
process.env.HOME = originalHome;
if (originalUserProfile !== undefined) process.env.USERPROFILE = originalUserProfile;
else delete process.env.USERPROFILE;
delete process.env.FAKE_CURSOR_AGENT_LOG;
delete process.env.FAKE_CURSOR_AGENT_STATUS_MODE;
fs.rmSync(tmpHome, { recursive: true, force: true });
},
};
try {
return await fn(env);
} finally {
env.cleanup();
}
}
async function createCursorConnection(overrides: Record<string, unknown> = {}) {
const connection = await providersDb.createProviderConnection({
provider: "cursor",
authType: "oauth",
email: `cursor-route-${Math.random()}@example.com`,
accessToken: "old-token",
refreshToken: null,
isActive: true,
testStatus: "active",
...overrides,
});
return getId(connection);
}
test("renewed: returns 200 with the documented shape and persists the new token", async () => {
await withCursorEnv(async (env) => {
const id = await createCursorConnection();
await env.writeIdeToken("new-ide-token", "new-machine");
const res = await callRoute(id);
const body = (await res.json()) as Record<string, unknown>;
assert.equal(res.status, 200);
assert.equal(body.success, true);
assert.equal(body.connectionId, id);
assert.equal(body.provider, "cursor");
assert.ok(body.expiresAt);
assert.ok(body.refreshedAt);
assert.equal(body.unchanged, undefined);
const updated = await providersDb.getProviderConnectionById(id);
assert.equal(updated?.accessToken, "new-ide-token");
assert.equal(updated?.testStatus, "active");
});
});
test("unchanged: returns 200 {success:true, unchanged:true, ...} without a new token", async () => {
await withCursorEnv(async () => {
const id = await createCursorConnection({
expiresAt: "2026-01-01T00:00:00.000Z",
});
// No IDE/agent fixtures written -> renewCursorConnection() reports "unchanged".
const res = await callRoute(id);
const body = (await res.json()) as Record<string, unknown>;
assert.equal(res.status, 200);
assert.equal(body.success, true);
assert.equal(body.unchanged, true);
assert.equal(body.connectionId, id);
assert.equal(body.provider, "cursor");
assert.equal(
body.expiresAt,
"2026-01-01T00:00:00.000Z",
"echoes the connection's CURRENT (unchanged) expiresAt"
);
assert.ok(body.refreshedAt);
assert.match(body.message as string, /already current/);
});
});
test(
'error: renewCursorConnection returning {status:"error"} -> 502',
{
skip:
"Same testability gap already flagged for C2/C3: renewCursorConnection() (called with " +
'no deps override here, same as the sweep) never returns {status:"error"} from a ' +
"black-box test because tryIdeAuth()/tryAgentAuth() catch every internal failure and " +
"resolve to {found:false,...} rather than throwing. This route's 502 mapping " +
'(`{error: "Token refresh failed — provider returned no new token", details: result.error}`) ' +
"is a straight passthrough of result.error, already proven correctly sanitized in " +
"tests/unit/cursor-renewal.test.ts's case (d).",
},
async () => {}
);
test("non-Cursor connection -> 400", async () => {
const connection = await providersDb.createProviderConnection({
provider: "openai",
authType: "oauth",
email: "not-cursor@example.com",
accessToken: "token",
refreshToken: "refresh",
isActive: true,
});
const id = getId(connection);
const res = await callRoute(id);
const body = (await res.json()) as Record<string, unknown>;
assert.equal(res.status, 400);
assert.match(body.error as string, /only supports Cursor connections/);
});
test("nonexistent connection -> 404", async () => {
const res = await callRoute("does-not-exist-" + Math.random());
const body = (await res.json()) as Record<string, unknown>;
assert.equal(res.status, 404);
assert.match(body.error as string, /not found/i);
});
test("a second call within the 30s cooldown returns 429 with Retry-After, without invoking renewCursorConnection again", async () => {
await withCursorEnv(async (env) => {
const id = await createCursorConnection();
// No fixtures -> first call resolves "unchanged", also sets the cooldown timestamp.
const first = await callRoute(id);
assert.equal(first.status, 200);
const invocationsAfterFirst = readLoggedInvocations(env.logPath).length;
assert.ok(
invocationsAfterFirst >= 1,
"expected the first call to actually check cursor-agent availability"
);
const second = await callRoute(id);
assert.equal(second.status, 429);
assert.ok(second.headers.get("Retry-After"), "expected a Retry-After header");
const secondBody = (await second.json()) as Record<string, unknown>;
assert.ok(typeof secondBody.retryAfterMs === "number");
assert.ok(
(secondBody.retryAfterMs as number) > 0 && (secondBody.retryAfterMs as number) <= 30_000
);
assert.equal(
readLoggedInvocations(env.logPath).length,
invocationsAfterFirst,
"renewCursorConnection() must NOT be invoked a second time while in cooldown"
);
});
});
test("a different connection's request is unaffected by another connection's cooldown", async () => {
await withCursorEnv(async () => {
const idA = await createCursorConnection();
const idB = await createCursorConnection();
const first = await callRoute(idA);
assert.equal(first.status, 200);
const second = await callRoute(idB);
assert.equal(
second.status,
200,
"a different connectionId must not be throttled by connection A's cooldown"
);
});
});
test("an unexpected thrown error is caught by the outer handler and returns 500 with sanitized details (no raw stack/path)", async () => {
const rawMessage =
"Simulated failure at /Users/secret-user/project/src/app/api/providers/[id]/refresh-cursor/route.ts:44:5";
const res = await POST(makeRequest(), {
params: Promise.reject(new Error(rawMessage)) as unknown as Promise<{ id: string }>,
});
const body = (await res.json()) as Record<string, unknown>;
assert.equal(res.status, 500);
assert.equal(body.error, "Token refresh failed");
const details = body.details as string;
assert.ok(!details.includes("/Users/secret-user"), `raw path leaked: ${details}`);
assert.ok(!details.includes("route.ts:44:5"), `raw source location leaked: ${details}`);
assert.ok(!details.includes("at /"), `stack-trace-style substring leaked: ${details}`);
});

View File

@@ -0,0 +1,69 @@
/**
* Security regression (Cursor renewal plan, Task 4): POST
* /api/providers/[id]/refresh-cursor manually nudges `cursor-agent` (a child
* process, via src/lib/cursor/renewal.ts) to attempt a Cursor session
* renewal. It MUST be classified LOCAL_ONLY so loopback enforcement runs
* unconditionally before any auth check — a leaked JWT via a Cloudflared/
* Ngrok tunnel cannot trigger a process spawn. Hard Rules #15 + #17. See
* docs/security/ROUTE_GUARD_TIERS.md.
*
* The refresh-cursor segment sits AFTER the dynamic `[id]` param, so it is
* matched by a regex in LOCAL_ONLY_API_PATTERNS rather than a flat prefix —
* classifying the whole `/api/providers/` subtree as LOCAL_ONLY would wrongly
* lock the remote dashboard out of ordinary provider CRUD (including the
* generic, remote-reachable `/refresh` route every OTHER provider uses).
* These tests pin BOTH the gate AND the narrowness (no over-match), mirroring
* tests/unit/route-guard-provider-login-local-only.test.ts's exact structure
* for the sibling `/login` regex entry.
*/
import test from "node:test";
import assert from "node:assert/strict";
import {
isLocalOnlyPath,
isLocalOnlyBypassableByManageScope,
} from "../../src/server/authz/routeGuard.ts";
test("/api/providers/[id]/refresh-cursor is LOCAL_ONLY (spawns cursor-agent)", () => {
assert.equal(isLocalOnlyPath("/api/providers/abc123/refresh-cursor"), true);
assert.equal(isLocalOnlyPath("/api/providers/conn-uuid-456/refresh-cursor"), true);
});
test("/api/providers/[id]/refresh-cursor with a trailing slash is LOCAL_ONLY", () => {
assert.equal(isLocalOnlyPath("/api/providers/abc123/refresh-cursor/"), true);
});
test("the dedicated-route decision worked: the generic /refresh route stays remote-reachable", () => {
// THE regression this whole route-split decision exists to prove: Cursor's
// manual-refresh gets its OWN dedicated LOCAL_ONLY route specifically so the
// pre-existing, shared /refresh route (used by every non-Cursor provider)
// is NOT reclassified and stays reachable from a remote dashboard.
assert.equal(isLocalOnlyPath("/api/providers/abc123/refresh"), false);
assert.equal(isLocalOnlyPath("/api/providers/conn-uuid-456/refresh"), false);
});
test("the refresh-cursor gate does NOT over-match the rest of /api/providers", () => {
assert.equal(isLocalOnlyPath("/api/providers"), false);
assert.equal(isLocalOnlyPath("/api/providers/"), false);
assert.equal(isLocalOnlyPath("/api/providers/abc123"), false);
assert.equal(isLocalOnlyPath("/api/providers/abc123/test"), false);
assert.equal(isLocalOnlyPath("/api/providers/abc123/models"), false);
// Anchored: extra segments after /refresh-cursor are not the spawn route.
assert.equal(isLocalOnlyPath("/api/providers/abc123/refresh-cursor/extra"), false);
// "refresh-cursor" must be its own segment, not a substring of the id.
assert.equal(isLocalOnlyPath("/api/providers/refresh-cursor-helper/status"), false);
});
test("isLocalOnlyBypassableByManageScope rejects refresh-cursor (spawn-capable, never bypassable via manage scope)", () => {
assert.equal(isLocalOnlyBypassableByManageScope("/api/providers/abc123/refresh-cursor"), false);
assert.equal(isLocalOnlyBypassableByManageScope("/api/providers/abc123/refresh-cursor/"), false);
});
test("isLocalOnlyBypassableByManageScope rejects login too — the retroactive gap-closure, not an incidental side effect", () => {
// Pins the plan's explicitly-noted side effect: the same SPAWN_CAPABLE_PATTERNS
// fix that protects refresh-cursor also retroactively closes a PRE-EXISTING
// gap for /login (which was in LOCAL_ONLY_API_PATTERNS but never in any
// spawn-capable deny-list before this plan). A regression here would mean
// a malformed DB bypass-prefix row could grant remote access to a route
// that spawns a headful Playwright Chromium.
assert.equal(isLocalOnlyBypassableByManageScope("/api/providers/abc123/login"), false);
});

View File

@@ -271,6 +271,75 @@ test("AC-8: PATCH with /api/cli-tools/runtime/ in bypass list → 400 BYPASS_PRE
assert.equal(snapshotAfter.enabled, snapshotBefore.enabled);
});
// ─── Cursor renewal plan, Task 4 Step 3: the new SPAWN_CAPABLE_PATTERNS /
// SPAWN_CAPABLE_PATTERN_ANCESTORS mechanism must reject a candidate bypass
// prefix of "/api/providers/" (which would otherwise cover both the new
// refresh-cursor route AND the pre-existing /login route) while leaving an
// unrelated, already-passing prefix untouched ────────────────────────────
test("PATCH with /api/providers/ in bypass list → 400 BYPASS_PREFIX_NOT_ALLOWED + snapshot unchanged (SPAWN_CAPABLE_PATTERN_ANCESTORS)", async () => {
process.env.JWT_SECRET = "test-jwt-secret-authz-bypass";
process.env.INITIAL_PASSWORD = "initial-pass-cursor-t4";
await settingsDb.updateSettings({ requireLogin: true });
const { ensurePersistentManagementPasswordHash } =
await import("../../../src/lib/auth/managementPassword.ts");
await ensurePersistentManagementPasswordHash({ source: "test.bootstrap" });
const seeded = await settingsDb.getSettings();
await runtime.applyRuntimeSettings(seeded);
const snapshotBefore = runtime.getAuthzBypassSnapshot();
const response = await settingsRoute.PATCH(
await makeManagementSessionRequest("http://localhost/api/settings", {
method: "PATCH",
body: {
localOnlyManageScopeBypassPrefixes: ["/api/mcp/", "/api/providers/"],
currentPassword: "initial-pass-cursor-t4",
},
})
);
assert.equal(response.status, 400);
const body = (await response.json()) as {
error: { details?: Array<{ field: string; message: string }> };
};
const offending = body.error.details?.find((d) =>
d.message.includes("BYPASS_PREFIX_NOT_ALLOWED")
);
assert.ok(offending, `expected BYPASS_PREFIX_NOT_ALLOWED in details: ${JSON.stringify(body)}`);
// The unrelated, already-passing "/api/mcp/" prefix is untouched by this
// rejection — no regression to the existing Layer-1 check: persisted state
// stays at its prior valid value, not silently split-accepted.
const settings = await settingsDb.getSettings();
assert.deepEqual(settings.localOnlyManageScopeBypassPrefixes, ["/api/mcp/"]);
const snapshotAfter = runtime.getAuthzBypassSnapshot();
assert.deepEqual(snapshotAfter.prefixes, snapshotBefore.prefixes);
assert.equal(snapshotAfter.enabled, snapshotBefore.enabled);
});
test("PATCH with ONLY the unrelated /api/mcp/ prefix still succeeds (no regression from the /api/providers/ ancestor check)", async () => {
process.env.JWT_SECRET = "test-jwt-secret-authz-bypass";
process.env.INITIAL_PASSWORD = "initial-pass-cursor-t4b";
await settingsDb.updateSettings({ requireLogin: true });
const { ensurePersistentManagementPasswordHash } =
await import("../../../src/lib/auth/managementPassword.ts");
await ensurePersistentManagementPasswordHash({ source: "test.bootstrap" });
const response = await settingsRoute.PATCH(
await makeManagementSessionRequest("http://localhost/api/settings", {
method: "PATCH",
body: {
localOnlyManageScopeBypassPrefixes: ["/api/mcp/"],
currentPassword: "initial-pass-cursor-t4b",
},
})
);
assert.equal(response.status, 200);
const settings = await settingsDb.getSettings();
assert.deepEqual(settings.localOnlyManageScopeBypassPrefixes, ["/api/mcp/"]);
});
// ─── Defence-in-depth: snapshot mutation alone cannot grant spawn bypass ─
test("Defence-in-depth: even if a malformed snapshot lists /api/cli-tools/runtime/, the runtime predicate rejects it", async () => {