mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-13 10:43:43 +03:00
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:
committed by
diegosouzapw
parent
71f1804e99
commit
df3b691809
@@ -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:
|
||||
|
||||
|
||||
124
src/app/api/providers/[id]/refresh-cursor/route.ts
Normal file
124
src/app/api/providers/[id]/refresh-cursor/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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/"];
|
||||
|
||||
@@ -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:
|
||||
|
||||
310
tests/unit/refresh-cursor-route.test.ts
Normal file
310
tests/unit/refresh-cursor-route.test.ts
Normal 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}`);
|
||||
});
|
||||
69
tests/unit/route-guard-cursor-refresh.test.ts
Normal file
69
tests/unit/route-guard-cursor-refresh.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user