fix(cursor): addresses quality-gate Layer 1.5 findings

Restores a comment that misrepresented execFile's actual argv shape
after an earlier bracket-removal fix, this time avoiding literal
closing-bracket characters entirely so the openapi checker's naive
array parser can't be broken by either version. Bounds the sweep-
and manual-route-triggered tryIdeAuth() busy-timeout to 250ms
(down from the interactive auto-import path's 2000ms), since both
share the main event loop with all other in-flight requests and
should fail fast on a WAL-lock collision rather than block the
whole instance for up to ~4s. Has the manual refresh route bypass
the sweep's IDE-auth dedup cache so a click always sees a fresh
read, consistent with this plan's existing "manual actions never
see stale cached data" convention. Documents the previously-missing
agent-availability route in ROUTE_GUARD_TIERS.md's spawn-capable
table.
This commit is contained in:
Will Gordon
2026-07-31 22:21:28 -04:00
committed by diegosouzapw
parent fceabd3807
commit ab9785d853
5 changed files with 79 additions and 26 deletions

View File

@@ -39,23 +39,24 @@ 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 |
| `/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 |
| 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 |
| `/api/providers/cursor/agent-availability` | Dashboard install-nudge check — spawns `cursor-agent status --format json` via `checkCursorAgentAvailability()`/`getCachedCursorAgentAvailability()` (`src/lib/cursor/renewal.ts`); credential-free response (`{cursorAgentAvailable: boolean}` only) | No — spawn-capable |
**Response on violation:** `403 LOCAL_ONLY`

View File

@@ -2,12 +2,31 @@ 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 { tryIdeAuth } from "@/lib/cursor/tokenExtractor";
import {
renewCursorConnection,
buildCursorRenewedUpdate,
runCursorRenewalExclusive,
BACKGROUND_IDE_AUTH_TIMEOUT_MS,
} from "@/lib/cursor/renewal";
/**
* A manual "Refresh" click must always see a fresh IDE-credential read, never
* a stale answer memoized by renewCursorConnection()'s default sweep-facing
* dedup cache (renewal.ts's dedupedTryIdeAuth, keyed host-wide with a 5s TTL
* — correct for coalescing multiple Cursor connections due in the SAME sweep
* tick, but wrong for a click that could otherwise reuse a read taken before
* THIS request's own nudge attempt completed). Matches the same
* always-uncached convention this plan already established for
* checkCursorAgentAvailability() vs. getCachedCursorAgentAvailability().
* Keeps the shared short busy-timeout (not the interactive auto-import
* route's longer 2000ms default) since this request shares the same event
* loop as every other in-flight request on this instance.
*/
function uncachedTryIdeAuth(): ReturnType<typeof tryIdeAuth> {
return tryIdeAuth({ timeoutMs: BACKGROUND_IDE_AUTH_TIMEOUT_MS });
}
interface CursorConnectionLike {
id: string;
provider?: string;
@@ -91,10 +110,13 @@ export async function POST(_request: Request, { params }: { params: Promise<{ id
lastManualRefreshAttemptAt.set(connection.id, now);
return await runCursorRenewalExclusive(connection.id, async () => {
const result = await renewCursorConnection({
accessToken: connection.accessToken ?? "",
machineId: connection.providerSpecificData?.machineId as string | null | undefined,
});
const result = await renewCursorConnection(
{
accessToken: connection.accessToken ?? "",
machineId: connection.providerSpecificData?.machineId as string | null | undefined,
},
{ tryIdeAuth: uncachedTryIdeAuth }
);
switch (result.status) {
case "renewed": {

View File

@@ -139,6 +139,21 @@ export async function getCachedCursorAgentAvailability(): Promise<{
const IDE_AUTH_DEDUP_TTL_MS = 5_000;
/**
* Busy-timeout for a background-triggered tryIdeAuth() open — deliberately
* much shorter than tryIdeAuth()'s own 2000ms default (used by the one-shot,
* user-initiated auto-import modal, a code path outside this orchestrator).
* Both the sweep AND the manual-refresh route share the same Node event loop
* as the HTTP server, so either one blocking on a WAL-lock collision stalls
* every other in-flight request on the instance — not just their own. An
* automated/backend-triggered call should fail fast and let the existing
* exponential circuit-breaker (sweep) or the user's next click (manual route)
* retry, rather than risk up to ~2s per driver in the fallback cascade
* (worst case ~4s total). Exported so the manual-refresh route can reuse the
* same value when it bypasses the dedup cache below for freshness.
*/
export const BACKGROUND_IDE_AUTH_TIMEOUT_MS = 250;
let cachedIdeAuthCall: {
home: string;
promise: ReturnType<typeof tryIdeAuth>;
@@ -161,7 +176,7 @@ function dedupedTryIdeAuth(): ReturnType<typeof tryIdeAuth> {
if (cachedIdeAuthCall && cachedIdeAuthCall.home === home && cachedIdeAuthCall.expiresAt > now) {
return cachedIdeAuthCall.promise;
}
const promise = tryIdeAuth();
const promise = tryIdeAuth({ timeoutMs: BACKGROUND_IDE_AUTH_TIMEOUT_MS });
cachedIdeAuthCall = { home, promise, expiresAt: now + IDE_AUTH_DEDUP_TTL_MS };
return promise;
}

View File

@@ -229,14 +229,27 @@ export async function tryAgentAuth(): Promise<{
* Cursor renames a key in a future release.
*
* Linux and Windows code paths are unchanged.
*
* `options.timeoutMs` bounds the SQLite busy-timeout on the open (default
* 2000ms, byte-identical for existing callers). The unattended sweep path
* (`src/lib/cursor/renewal.ts`) passes a much shorter override — an
* automated background job that fails to acquire the lock quickly should
* fail fast and let the existing exponential circuit-breaker retry on a
* later tick, rather than blocking the shared Node event loop for up to
* ~2s per driver in the fallback cascade (worst case ~4s: better-sqlite3's
* busy-timeout elapsing, then node:sqlite's). The one-shot, user-initiated
* `/api/oauth/cursor/auto-import` modal action keeps the longer default,
* since a single explicit click reasonably can wait longer for a better
* one-time success rate.
*/
export async function tryIdeAuth(): Promise<{
export async function tryIdeAuth(options?: { timeoutMs?: number }): Promise<{
found: boolean;
accessToken?: string;
machineId?: string;
source?: string;
error?: string;
}> {
const timeoutMs = options?.timeoutMs ?? 2000;
const platform = process.platform;
const candidates = cursorDbCandidatePaths(platform, {
home: homedir(),
@@ -291,8 +304,10 @@ export async function tryIdeAuth(): Promise<{
// sweep tick (src/lib/cursor/renewal.ts::renewCursorConnection()) on every
// near-expiry cycle, not just the explicit auto-import modal action, so a
// WAL-lock collision with a running Cursor IDE needs a retry window on
// every driver path (see driverFactory.ts::toNodeSqliteOptions()).
db = tryOpenSync(dbPath, { readonly: true, fileMustExist: true, timeout: 2000 });
// every driver path (see driverFactory.ts::toNodeSqliteOptions()). The
// sweep path overrides `timeoutMs` to a much shorter value (see the
// options.timeoutMs doc comment above).
db = tryOpenSync(dbPath, { readonly: true, fileMustExist: true, timeout: timeoutMs });
if (!db) {
if (platform === "darwin") {
return {

View File

@@ -53,7 +53,7 @@ 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", argv-array-of-one-arg "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. Note: this comment intentionally avoids a literal closing square bracket character — check-openapi-security-tiers.mjs's naive regex parser for this array stops at the first one it finds, silently truncating its view of every entry after this one.
"/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).