diff --git a/.github/scripts/sync-modules-vendor.json b/.github/scripts/sync-modules-vendor.json index 4ca704b..b683033 100644 --- a/.github/scripts/sync-modules-vendor.json +++ b/.github/scripts/sync-modules-vendor.json @@ -1,7 +1,13 @@ { "repo": "JFROG/jfrog-agent-hooks", - "pin": "jfrog-agent-hooks/v0.8.1", + "pin": "jfrog-agent-hooks/v0.8.1+mld-1386-rewrite-core@a146a13", + "dest_prefix": "plugins/jfrog", "paths": [ "modules" + ], + "keep": [ + "modules/core/rewrite-mcp-json.mjs", + "modules/core/agent-guard-check.mjs", + "modules/core/entry.mjs" ] } diff --git a/.github/scripts/sync-modules.mjs b/.github/scripts/sync-modules.mjs index 99a5d29..e98520a 100644 --- a/.github/scripts/sync-modules.mjs +++ b/.github/scripts/sync-modules.mjs @@ -6,10 +6,15 @@ // // Defaults JFROG_AGENT_HOOKS_PATH to ../jfrog-agent-hooks (sibling clone). // Reads paths from sync-modules-vendor.json. +// +// Optional vendor.keep: dest-relative file paths restored after sync so a +// temporary overlay (e.g. MLD-1386 core files) is not wiped until upstream +// ships them and keep is removed. import { promises as fs } from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; +import process from "node:process"; +import { fileURLToPath, pathToFileURL } from "node:url"; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptDir, "..", ".."); @@ -24,6 +29,37 @@ async function fileExists(p) { } } +/** + * @param {string} destRoot + * @param {string[]} keepRels — paths relative to destRoot + * @returns {Promise>} + */ +export async function stashKeepFiles(destRoot, keepRels) { + /** @type {Map} */ + const stash = new Map(); + for (const rel of keepRels) { + if (typeof rel !== "string" || !rel.trim()) continue; + const normalized = rel.replace(/^\/+/, ""); + const full = path.join(destRoot, normalized); + if (!(await fileExists(full))) continue; + stash.set(normalized, await fs.readFile(full)); + } + return stash; +} + +/** + * @param {string} destRoot + * @param {Map} stash + */ +export async function restoreKeepFiles(destRoot, stash) { + for (const [rel, buf] of stash) { + const full = path.join(destRoot, rel); + await fs.mkdir(path.dirname(full), { recursive: true }); + await fs.writeFile(full, buf); + console.log(` keep restored: ${rel}`); + } +} + async function copyPath(fromDir, toDir, relativePath) { const from = path.join(fromDir, relativePath); const to = path.join(toDir, relativePath); @@ -42,6 +78,7 @@ async function main() { if (!Array.isArray(paths) || paths.length === 0) { throw new Error(`${vendorPath} must define a non-empty paths array`); } + const keep = Array.isArray(vendor.keep) ? vendor.keep : []; const hooksRoot = process.env.JFROG_AGENT_HOOKS_PATH?.trim() || @@ -57,10 +94,24 @@ async function main() { const destRoot = destPrefix ? path.join(repoRoot, destPrefix) : repoRoot; console.log(`--- sync from ${hooksRoot} (pin: ${vendor.pin ?? "local"}) ---`); + const stash = await stashKeepFiles(destRoot, keep); for (const rel of paths) { await copyPath(hooksRoot, destRoot, rel); } + await restoreKeepFiles(destRoot, stash); console.log("done."); } -await main(); +function isMainModule() { + const entry = process.argv[1]; + if (!entry) return false; + try { + return pathToFileURL(path.resolve(entry)).href === import.meta.url; + } catch { + return false; + } +} + +if (isMainModule()) { + await main(); +} diff --git a/.github/scripts/sync-modules.test.mjs b/.github/scripts/sync-modules.test.mjs new file mode 100644 index 0000000..d620092 --- /dev/null +++ b/.github/scripts/sync-modules.test.mjs @@ -0,0 +1,32 @@ +// Copyright (c) JFrog Ltd. 2026 +// Licensed under the Apache License, Version 2.0 +// https://www.apache.org/licenses/LICENSE-2.0 + +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + restoreKeepFiles, + stashKeepFiles, +} from "./sync-modules.mjs"; + +test("stashKeepFiles + restoreKeepFiles round-trip overlay files", async () => { + const destRoot = mkdtempSync(path.join(tmpdir(), "sync-keep-")); + const rel = path.join("modules", "core", "rewrite-mcp-json.mjs"); + const full = path.join(destRoot, rel); + mkdirSync(path.dirname(full), { recursive: true }); + writeFileSync(full, "overlay-v1\n"); + + const stash = await stashKeepFiles(destRoot, [rel, "modules/core/missing.mjs"]); + assert.equal(stash.size, 1); + assert.equal(stash.get(rel)?.toString("utf8"), "overlay-v1\n"); + + // Simulate sync wiping the tree. + writeFileSync(full, "upstream-empty\n"); + + await restoreKeepFiles(destRoot, stash); + assert.equal(readFileSync(full, "utf8"), "overlay-v1\n"); +}); diff --git a/VENDOR.md b/VENDOR.md index db278c7..5a2bf7e 100644 --- a/VENDOR.md +++ b/VENDOR.md @@ -38,10 +38,14 @@ The `plugins/jfrog/modules/` bundle is vendored from **jfrog-agent-hooks** (GHE) The bundle contains harness runners (`core/`, `cursor-session-start.mjs`), the `package-resolution/` capability, and `assets/agents-default-conf.json`. Automated sync PRs (`chore/sync-modules-v*`) update this tree on each `jfrog-agent-hooks` release. +Harness-specific scripts (for example `plugins/jfrog/scripts/cursor-align-mcp-json.mjs` and `cursor-mcp-json-discover.mjs`) live **outside** `modules/` so sync does not wipe them. They call shared orchestration in synced `modules/core/` (for example `rewrite-mcp-json.mjs`). + +Until the next `jfrog-agent-hooks` release that includes MLD-1386, `modules/core/{rewrite-mcp-json,agent-guard-check,entry}.mjs` are vendored from that branch on top of the pinned `v0.8.1` tree (see the vendor pin suffix). Those paths are listed in `keep` in [`sync-modules-vendor.json`](.github/scripts/sync-modules-vendor.json) so a sync run restores them after replacing `modules/`. Remove the `keep` entries once upstream includes the files and the pin no longer needs the overlay. + ## Refreshing modules ```bash JFROG_AGENT_HOOKS_PATH=/path/to/jfrog-agent-hooks node .github/scripts/sync-modules.mjs ``` -The script reads `paths` from `sync-modules-vendor.json` (today: `["modules"]`) and replaces the whole `plugins/jfrog/modules/` tree. +The script reads `paths`, `dest_prefix`, and optional `keep` from `sync-modules-vendor.json` (today: `paths: ["modules"]`, `dest_prefix: "plugins/jfrog"`) and replaces the whole `plugins/jfrog/modules/` tree, then restores any `keep` files that existed before the sync. diff --git a/plugins/jfrog/README.md b/plugins/jfrog/README.md index ea300c0..d053e59 100644 --- a/plugins/jfrog/README.md +++ b/plugins/jfrog/README.md @@ -20,6 +20,7 @@ CLI authentication options: run `jf login` for browser-based setup, or set the ` |---|---|---| | **MCP** | `mcp.json` | Remote JFrog MCP server (OAuth, no API keys) | | **Hook + Skill** | `hooks/hooks.json`, `skills/jfrog-setup-package-managers/` | Agent Package Resolution (Preview) — route agent package installs through Artifactory | +| **Hook** | `hooks/hooks.json`, `scripts/cursor-align-mcp-json.mjs` (+ `cursor-mcp-json-discover.mjs`) | On session start, rewrite discovered plugin `mcp.json` / `.mcp.json` files through Agent Guard (`--rewrite-mcp-json`) so stdio MCP entries launch via `@jfrog/agent-guard` | ### Skills @@ -46,6 +47,25 @@ Agent Package Resolution is in preview and opt-in. To get started: - **Users:** see the [User Guide](https://github.com/jfrog/cursor-plugin/blob/main/docs/package-resolution-user-guide.md). - **Admins:** see the [Admin Guide](https://github.com/jfrog/cursor-plugin/blob/main/docs/package-resolution-admin-guide.md). +## Plugin MCP rewrite (Agent Guard) + +On every Cursor agent `sessionStart`, the plugin discovers plugin `mcp.json` and `.mcp.json` files under `~/.cursor/plugins/local/*` and `~/.cursor/plugins/cache/*` (marketplace installs), plus this plugin's own configs, and runs `npx @jfrog/agent-guard --rewrite-mcp-json` against those paths. Cursor can load servers from both files when both exist. Stdio MCP entries are rewritten to launch through Agent Guard; remote `url` / `http` / `sse` / `ws` entries are left unchanged. Workspace and user-level `.cursor/mcp.json` files are **not** rewritten. If a file is rewritten, the sessionStart hook asks you to **open a new session** so Cursor reconnects those MCPs. + +Marketplace installs under `~/.cursor/plugins/cache` are rewritten by default (opt out via env below). Auto-discovered roots must resolve under `~/.cursor` (symlink escapes are skipped); `JF_ALIGN_MCP_JSON_ROOTS` overrides are trusted as-is and skip this plugin's own `mcp.json` unless you list that root yourself. `CURSOR_CONFIG_DIR` (CLI config) is **not** used for plugin discovery — Cursor loads plugins from `~/.cursor` regardless. + +The hook soft-fails (never breaks the session): missing project key, Agent Guard gate failure, or rewrite errors log and exit 0. + +| Env | Purpose | +|---|---| +| `JF_AGENT_REWRITE_MCP_JSON_DISABLE=1` | Kill switch — skip rewrite entirely | +| `JF_PROJECT` / `JFROG_PROJECT` | Project key (also inferred from existing `_JF_ARGS project=` in discovered mcp.json) | +| `JF_SERVER` / `JFROG_SERVER_ID` | Optional server ID for the gate / `--server` | +| `JFROG_AGENT_GUARD_VERSION` | Override pinned `@jfrog/agent-guard` version | +| `JFROG_AGENT_GUARD_REPO` | Private npm registry for `@jfrog/agent-guard` | +| `JFROG_AGENT_GUARD_BIN` | Local Agent Guard binary (skips npx) | +| `JF_ALIGN_MCP_JSON_ROOTS` | Replace discovery roots entirely (POSIX `:`/`,`; Windows `;`/`,`). Does not auto-include this plugin's own `mcp.json` | +| `JF_ALIGN_MCP_JSON_SKIP_CACHE=1` | Skip `~/.cursor/plugins/cache` (marketplace installs; scanned by default) | + ## MCP Capabilities The JFrog MCP Server provides: diff --git a/plugins/jfrog/hooks/hooks.json b/plugins/jfrog/hooks/hooks.json index a5afe5d..0cd3ccd 100644 --- a/plugins/jfrog/hooks/hooks.json +++ b/plugins/jfrog/hooks/hooks.json @@ -5,6 +5,10 @@ { "command": "node \"./modules/cursor-session-start.mjs\" package-resolution", "timeout": 7 + }, + { + "command": "node \"./scripts/cursor-align-mcp-json.mjs\" session-start", + "timeout": 60 } ] } diff --git a/plugins/jfrog/modules/core/agent-guard-check.mjs b/plugins/jfrog/modules/core/agent-guard-check.mjs new file mode 100644 index 0000000..8789667 --- /dev/null +++ b/plugins/jfrog/modules/core/agent-guard-check.mjs @@ -0,0 +1,334 @@ +#!/usr/bin/env node +// JFrog Agent Guard activation check +// +// Silent gate for session hooks. Determines whether Agent Guard is enabled +// for the current environment. +// +// Contract (key off `code`, not `reason` text): +// - code 0 -> Agent Guard ENABLED (caller may proceed) +// - code 2 -> reachable but the platform has the MCP registry DISABLED +// - code 1 -> DISABLED for any other reason: no credentials, timeout, +// network/DNS error (caller must silently abort) +// +// Set JF_AGENT_GUARD_DEBUG=true for verbose tracing on stderr. +// Library callers use runAgentGuardCheck(); CLI entry calls process.exit. + +import { execFileSync } from "node:child_process"; +import process from "node:process"; + +import { isMainEntry } from "./entry.mjs"; + +export const SETTINGS_PATH = + "/ml/core/api/v1/administration/account-settings/mcp_gateway_plugin_enabled"; +export const REQUEST_TIMEOUT_MS = 5000; + +export const EXIT_ENABLED = 0; +export const EXIT_DISABLED = 1; +export const EXIT_REGISTRY_DISABLED = 2; + +/** + * @param {NodeJS.ProcessEnv} [env] + * @param {string} newName + * @param {string} [oldName] + * @returns {string | undefined} + */ +function envLookup(env, newName, oldName) { + const raw = env[newName] ?? (oldName ? env[oldName] : undefined); + if (typeof raw !== "string") return undefined; + const trimmed = raw.trim(); + return trimmed || undefined; +} + +/** + * @param {NodeJS.ProcessEnv} [env] + * @param {(message: string) => void} [debug] + */ +function makeDebug(env, debug) { + if (typeof debug === "function") return debug; + const enabled = env.JF_AGENT_GUARD_DEBUG === "true"; + return (message) => { + if (enabled) console.error(`[jfrog-agent-guard] ${message}`); + }; +} + +/** + * Resolve credentials from Path A (environment variables) or Path B + * (JFrog CLI configuration). + * + * Intentionally distinct from `jf-identity.mjs`: + * - package-resolution identity is always `jf config` and may use Basic auth; + * - Agent Guard's settings probe needs a Bearer access token, and mirrors the + * AG CLI by preferring JFROG_URL/JF_URL + access token when set. + * - When `serverId` is set: that jf server first, then env, never the default + * CLI server. Without `serverId`: env first, then default `jf config export`. + * Do not reuse getPlatformIdentity() here without preserving that contract. + * + * @param {{ + * serverId?: string, + * env?: NodeJS.ProcessEnv, + * execFileSyncFn?: typeof execFileSync, + * debug?: (message: string) => void, + * }} [opts] + * @returns {{ baseUrl: string, token: string, source: string } | null} + */ +export function resolveAgentGuardCredentials(opts = {}) { + const env = opts.env ?? process.env; + const debug = makeDebug(env, opts.debug); + const explicitServerId = opts.serverId?.trim() || undefined; + const execFn = opts.execFileSyncFn ?? execFileSync; + + if (explicitServerId) { + const fromCli = resolveFromCliConfig({ + serverId: explicitServerId, + execFileSyncFn: execFn, + debug, + }); + if (fromCli) return fromCli; + debug( + "Explicit server ID did not resolve via jf config; falling back to env credentials.", + ); + } + + const envUrl = envLookup(env, "JFROG_URL", "JF_URL"); + const envToken = envLookup(env, "JFROG_ACCESS_TOKEN", "JF_ACCESS_TOKEN"); + if (envUrl && envToken) { + debug("Using credentials from environment variables (Path A)."); + return { + baseUrl: envUrl, + token: envToken, + source: "environment variables", + }; + } + debug( + "Environment credentials incomplete; trying JFrog CLI config (Path B).", + ); + + if (explicitServerId) return null; + return resolveFromCliConfig({ + serverId: undefined, + execFileSyncFn: execFn, + debug, + }); +} + +/** + * @param {{ + * serverId?: string, + * execFileSyncFn?: typeof execFileSync, + * debug?: (message: string) => void, + * }} opts + */ +function resolveFromCliConfig(opts) { + const debug = opts.debug ?? (() => {}); + const execFn = opts.execFileSyncFn ?? execFileSync; + const exportArgs = opts.serverId + ? ["config", "export", opts.serverId] + : ["config", "export"]; + let exported; + try { + exported = execFn("jf", exportArgs, { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 2000, + }).trim(); + } catch (error) { + debug( + `'jf config export' failed (jf not on PATH or no server configured): ${error?.message}`, + ); + return null; + } + + let cfg; + try { + cfg = JSON.parse(Buffer.from(exported, "base64").toString("utf8")); + } catch (error) { + debug(`Could not decode the jf config export token: ${error?.message}`); + return null; + } + + const baseUrl = cfg?.url; + const token = cfg?.accessToken; + if (!baseUrl) { + debug("Exported JFrog CLI config has no platform URL."); + return null; + } + if (!token) { + debug( + "Exported JFrog CLI config has no access token (bearer auth needed).", + ); + return null; + } + + const id = cfg?.serverId ?? "default"; + return { + baseUrl, + token, + source: `JF CLI config (server '${id}')`, + }; +} + +/** + * @param {string} baseUrl + * @param {string} token + * @param {{ + * fetchFn?: typeof fetch, + * timeoutMs?: number, + * debug?: (message: string) => void, + * }} [opts] + */ +export async function isGatewayPluginEnabled(baseUrl, token, opts = {}) { + const debug = opts.debug ?? (() => {}); + const fetchFn = opts.fetchFn ?? fetch; + const timeoutMs = opts.timeoutMs ?? REQUEST_TIMEOUT_MS; + + const root = baseUrl.replace(/\/+$/, "").replace(/\/artifactory$/, ""); + const url = root + SETTINGS_PATH; + debug(`Fetching gateway plugin setting from ${url}`); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetchFn(url, { + method: "GET", + headers: { + Accept: "application/json", + Authorization: `Bearer ${token}`, + }, + signal: controller.signal, + }); + if (!response.ok) { + debug(`Settings request returned HTTP ${response.status}.`); + return { + ok: false, + reason: `settings endpoint returned HTTP ${response.status}`, + }; + } + const data = await response.json(); + const unwrap = (v) => (v !== null && typeof v === "object" ? v?.value : v); + const container = data?.settings ?? data; + const named = + container?.mcpGatewayPluginEnabled ?? + container?.mcp_gateway_plugin_enabled; + const value = + typeof data === "boolean" + ? data + : named !== undefined + ? unwrap(named) + : unwrap(container); + debug(`Settings response indicates gateway plugin enabled=${value}.`); + if (value === true) return { ok: true }; + if (value === false) { + return { + ok: false, + registryOff: true, + reason: "mcp gateway plugin setting returned false", + }; + } + return { + ok: false, + reason: "settings endpoint returned an invalid gateway-plugin setting", + }; + } catch (error) { + const reason = + error?.name === "AbortError" + ? "timeout" + : (error?.message ?? "unknown error"); + debug(`Settings request failed: ${reason}`); + return { + ok: false, + reason: `settings endpoint unreachable (${reason})`, + }; + } finally { + clearTimeout(timeout); + } +} + +/** + * Run the Agent Guard activation check without exiting the process. + * @param {{ + * serverId?: string, + * env?: NodeJS.ProcessEnv, + * fetchFn?: typeof fetch, + * execFileSyncFn?: typeof execFileSync, + * timeoutMs?: number, + * debug?: (message: string) => void, + * }} [opts] + * @returns {Promise<{ code: number, reason: string }>} + */ +export async function runAgentGuardCheck(opts = {}) { + const env = opts.env ?? process.env; + const debug = makeDebug(env, opts.debug); + + try { + const forceDisabled = + envLookup(env, "_JF_AGENT_GUARD_FORCE_DISABLE") === "true"; + const forceEnabled = + envLookup(env, "JF_AGENT_GUARD_FORCE_ENABLE") === "true"; + if (forceDisabled) { + return { + code: EXIT_DISABLED, + reason: "Disabled: forced via _JF_AGENT_GUARD_FORCE_DISABLE", + }; + } + if (forceEnabled) { + return { + code: EXIT_ENABLED, + reason: "Enabled: forced via JF_AGENT_GUARD_FORCE_ENABLE", + }; + } + + const creds = resolveAgentGuardCredentials({ + serverId: opts.serverId, + env, + execFileSyncFn: opts.execFileSyncFn, + debug, + }); + if (!creds) { + return { + code: EXIT_DISABLED, + reason: + "Disabled: JFROG_URL/JF_URL + access token not set and no default JF CLI config found", + }; + } + + const result = await isGatewayPluginEnabled(creds.baseUrl, creds.token, { + fetchFn: opts.fetchFn, + timeoutMs: opts.timeoutMs, + debug, + }); + if (result.ok) { + return { + code: EXIT_ENABLED, + reason: `Enabled: via ${creds.source}`, + }; + } + if (result.registryOff) { + return { + code: EXIT_REGISTRY_DISABLED, + reason: `RegistryDisabled: ${result.reason}`, + }; + } + return { + code: EXIT_DISABLED, + reason: `Disabled: ${result.reason}`, + }; + } catch (error) { + debug(`Unexpected error: ${error?.stack ?? error?.message ?? error}`); + return { code: EXIT_DISABLED, reason: "Disabled: unexpected error" }; + } +} + +async function main() { + const result = await runAgentGuardCheck({ + serverId: process.argv[2], + }); + process.stdout.write(`${result.reason}\n`); + process.exit(result.code); +} + +if (isMainEntry(import.meta.url)) { + main().catch((error) => { + console.error(`[jfrog-agent-guard] Unexpected error: ${error?.message}`); + process.exit(EXIT_DISABLED); + }); +} diff --git a/plugins/jfrog/modules/core/entry.mjs b/plugins/jfrog/modules/core/entry.mjs new file mode 100644 index 0000000..476d681 --- /dev/null +++ b/plugins/jfrog/modules/core/entry.mjs @@ -0,0 +1,36 @@ +// Shared "was this module run as the CLI entrypoint?" check for the adapters. +// +// Claude invokes hooks as `${CLAUDE_PLUGIN_ROOT}/modules/.mjs`, and a +// plugin install directory is often a symlink. Node resolves the main entry to +// its real path before assigning import.meta.url, so comparing against a raw +// path.resolve(process.argv[1]) reports false under a symlinked layout and the +// hook silently becomes a no-op with exit code 0. Compare against both. + +import { realpathSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; + +/** + * @param {string} moduleUrl — the caller's import.meta.url + * @param {string} [entry] — defaults to process.argv[1] + */ +export function isMainEntry(moduleUrl, entry = process.argv[1]) { + if (!entry) return false; + + try { + const resolved = path.resolve(entry); + let real = resolved; + try { + real = realpathSync(resolved); + } catch { + // Entry may not exist on disk (e.g. a virtual entrypoint); use as-is. + } + return ( + moduleUrl === pathToFileURL(real).href || + moduleUrl === pathToFileURL(resolved).href + ); + } catch { + return false; + } +} diff --git a/plugins/jfrog/modules/core/rewrite-mcp-json.mjs b/plugins/jfrog/modules/core/rewrite-mcp-json.mjs new file mode 100644 index 0000000..5170c8e --- /dev/null +++ b/plugins/jfrog/modules/core/rewrite-mcp-json.mjs @@ -0,0 +1,744 @@ +// Shared Agent Guard `--rewrite-mcp-json` runner for harness adapters. +// +// Harness plugins own path discovery; this module owns discover → project/ +// server resolution → Step 0 gating → spawn/timeout, and soft-fail +// orchestration. Server id is resolved once for both the gate and AG --server. +// +// Usage (from a thin Cursor/Claude script next to synced modules/): +// import { runRewriteMcpJsonPipeline } from "./modules/core/rewrite-mcp-json.mjs"; +// const { code, rewritten } = await runRewriteMcpJsonPipeline({ +// discover: () => [...absoluteMcpJsonPaths], +// allowRoots: [...], +// }); +// // code is always 0 (soft-fail); rewritten > 0 when Agent Guard updated files. +// +// Kill switch: JF_AGENT_REWRITE_MCP_JSON_DISABLE=1 → soft no-op (exit 0). +// Local binary: JFROG_AGENT_GUARD_BIN=/path/to/agent-guard (skips npx). +// Version pin: JFROG_AGENT_GUARD_VERSION (default DEFAULT_AGENT_GUARD_VERSION). + +import { spawn } from "node:child_process"; +import { readFileSync } from "node:fs"; +import process from "node:process"; + +import { EXIT_ENABLED, runAgentGuardCheck } from "./agent-guard-check.mjs"; +import { createLogger } from "./logger.mjs"; + +const log = createLogger("rewrite-mcp-json"); + +export const AGENT_GUARD_PACKAGE = "@jfrog/agent-guard"; +export const DISABLE_ENV = "JF_AGENT_REWRITE_MCP_JSON_DISABLE"; +export const AGENT_GUARD_BIN_ENV = "JFROG_AGENT_GUARD_BIN"; +/** + * Default npm registry for `npx @jfrog/agent-guard` during mcp.json rewrite. + * + * Exception to the usual "no runtime hard-dep on releases.jfrog.io" bundling + * rule: package-resolution hooks are fully vendored, but Agent Guard's MCP + * rewrite intentionally fetches `@jfrog/agent-guard` at session start via + * npx from the public `coding-agents-npm` channel (override with + * JFROG_AGENT_GUARD_REPO / JFROG_AGENT_GUARD_BIN). See .cursor/rules/bundling.mdc. + */ +export const DEFAULT_AGENT_GUARD_NPM_REGISTRY = + "https://releases.jfrog.io/artifactory/api/npm/coding-agents-npm/"; +/** + * Pinned so a session start cannot execute whatever the registry currently + * tags as latest. Bump deliberately; JFROG_AGENT_GUARD_VERSION overrides + * (including "latest"). + */ +export const DEFAULT_AGENT_GUARD_VERSION = "1.6.0"; +/** Shared budget for rewriting all discovered files in one hook invocation. */ +/** Must leave headroom under Cursor hooks.json timeout (60s) for gate + overhead. */ +export const DEFAULT_REWRITE_TIMEOUT_MS = 35_000; +/** SIGTERM → SIGKILL escalation window for a child that ignores the first signal. */ +export const DEFAULT_KILL_GRACE_MS = 2_000; + +export function isRewriteDisabled(env = process.env) { + return env[DISABLE_ENV] === "1"; +} + +/** + * True when JFROG_URL/JF_URL + access token are set — AG reads env directly, + * so callers must omit `--server`. + * @param {NodeJS.ProcessEnv} [env] + */ +export function hasJfrogUrlTokenEnv(env = process.env) { + const url = env.JFROG_URL?.trim() || env.JF_URL?.trim(); + const token = env.JFROG_ACCESS_TOKEN?.trim() || env.JF_ACCESS_TOKEN?.trim(); + return Boolean(url && token); +} + +/** + * @param {unknown} value + * @returns {value is Record} + */ +function isPlainObject(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Scan mcp.json files for an existing Agent Guard `_JF_ARGS` project= value. + * @param {string[]} mcpPaths + * @param {{ readFileSyncFn?: typeof readFileSync }} [opts] + * @returns {string} + */ +export function scanMcpJsonForProject(mcpPaths, opts = {}) { + const readFn = opts.readFileSyncFn ?? readFileSync; + for (const mcpPath of mcpPaths ?? []) { + const servers = readMcpServers(mcpPath, readFn); + if (!servers) continue; + for (const entry of Object.values(servers)) { + if (!isPlainObject(entry)) continue; + const envBlock = entry.env; + if (!isPlainObject(envBlock)) continue; + const jfArgs = envBlock._JF_ARGS; + if (typeof jfArgs !== "string") continue; + const match = /(?:^|&)project=([^&]*)/.exec(jfArgs); + const project = match?.[1]?.trim(); + if (project) return project; + } + } + return ""; +} + +/** + * Scan mcp.json files for an existing Agent Guard `--server ` in args. + * @param {string[]} mcpPaths + * @param {{ readFileSyncFn?: typeof readFileSync }} [opts] + * @returns {string} + */ +export function scanMcpJsonForServerId(mcpPaths, opts = {}) { + const readFn = opts.readFileSyncFn ?? readFileSync; + for (const mcpPath of mcpPaths ?? []) { + const servers = readMcpServers(mcpPath, readFn); + if (!servers) continue; + for (const entry of Object.values(servers)) { + if (!isPlainObject(entry)) continue; + const args = entry.args; + if (!Array.isArray(args)) continue; + for (let i = 0; i < args.length; i++) { + if (args[i] === "--server" && typeof args[i + 1] === "string") { + const id = args[i + 1].trim(); + if (id) return id; + } + } + } + } + return ""; +} + +/** + * @param {string} mcpPath + * @param {typeof readFileSync} readFn + * @returns {Record | null} + */ +function readMcpServers(mcpPath, readFn) { + try { + const raw = readFn(mcpPath, "utf8"); + const parsed = JSON.parse(raw); + if (!isPlainObject(parsed) || !isPlainObject(parsed.mcpServers)) { + return null; + } + return /** @type {Record} */ (parsed.mcpServers); + } catch { + return null; + } +} + +/** + * Resolve JFrog project key: env → existing AG `_JF_ARGS` → "". + * @param {NodeJS.ProcessEnv} [env] + * @param {{ + * mcpPaths?: string[], + * readFileSyncFn?: typeof readFileSync, + * }} [opts] + * @returns {string} + */ +export function resolveRewriteProject(env = process.env, opts = {}) { + const fromEnv = env.JF_PROJECT?.trim() || env.JFROG_PROJECT?.trim() || ""; + if (fromEnv) return fromEnv; + return scanMcpJsonForProject(opts.mcpPaths ?? [], { + readFileSyncFn: opts.readFileSyncFn, + }); +} + +/** + * Resolve server ID for gate + rewrite (same priority both places): + * omit when URL+token env → existing AG `--server` in mcp.json → + * `serverIdHint` → JF_SERVER / JFROG_SERVER_ID → "". + * @param {NodeJS.ProcessEnv} [env] + * @param {{ + * mcpPaths?: string[], + * readFileSyncFn?: typeof readFileSync, + * serverIdHint?: string, + * }} [opts] + * @returns {string} + */ +export function resolveRewriteServerId(env = process.env, opts = {}) { + if (hasJfrogUrlTokenEnv(env)) return ""; + const fromMcp = scanMcpJsonForServerId(opts.mcpPaths ?? [], { + readFileSyncFn: opts.readFileSyncFn, + }); + if (fromMcp) return fromMcp; + const hint = opts.serverIdHint?.trim(); + if (hint) return hint; + return env.JF_SERVER?.trim() || env.JFROG_SERVER_ID?.trim() || ""; +} + +/** + * @param {NodeJS.Platform} [platform] + */ +export function resolveNpxCommand(platform = process.platform) { + return platform === "win32" ? "npx.cmd" : "npx"; +} + +/** + * @param {NodeJS.ProcessEnv} env + * @param {NodeJS.Platform} [platform] + * @param {{ local?: boolean }} [opts] + */ +export function buildNpxSpawnOptions( + env, + platform = process.platform, + opts = {}, +) { + const isWin = platform === "win32"; + const useShell = isWin && !opts.local; + return { + stdio: /** @type {const} */ (["pipe", "pipe", "pipe"]), + env, + // Pin cmd.exe — shell: true would honor ComSpec (e.g. PowerShell). + shell: useShell ? "cmd.exe" : false, + detached: !isWin, + }; +} + +/** + * CRT-quote a single argv token for Node spawn under shell: "cmd.exe". + * @param {string} arg + * @returns {string} + * @throws {Error} when the arg contains CR/LF + */ +export function quoteWindowsArg(arg) { + const value = String(arg ?? ""); + if (/[\r\n]/.test(value)) { + throw new Error("Windows spawn arg must not contain CR/LF"); + } + return `"${value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, "$1$1")}"`; +} + +/** + * @param {string[]} args + * @param {NodeJS.Platform} [platform] + * @returns {string[]} + */ +export function quoteSpawnArgs(args, platform = process.platform) { + return platform === "win32" ? args.map(quoteWindowsArg) : args; +} + +/** + * @param {{ pid?: number, kill?: (signal?: string) => boolean }} child + * @param {{ + * platform?: NodeJS.Platform, + * killFn?: (pid: number, signal?: string) => true, + * spawnFn?: typeof spawn, + * graceMs?: number, + * isAlive?: () => boolean, + * waitForExit?: Promise, + * }} [opts] + * @returns {Promise} + */ +export async function killRewriteChildTree(child, opts = {}) { + const platform = opts.platform ?? process.platform; + const killFn = opts.killFn ?? process.kill; + const spawnFn = opts.spawnFn ?? spawn; + const graceMs = opts.graceMs ?? DEFAULT_KILL_GRACE_MS; + const isAlive = opts.isAlive ?? (() => true); + + const signalChild = (signal) => { + try { + child?.kill?.(signal); + } catch { + // Already gone. + } + }; + + const signalTree = (signal) => { + if (platform === "win32") { + if (child?.pid) { + try { + const killer = spawnFn( + "taskkill", + ["/pid", String(child.pid), "/T", "/F"], + { stdio: "ignore" }, + ); + killer?.on?.("error", () => {}); + return; + } catch { + // fall through + } + } + signalChild(signal); + return; + } + + if (child?.pid) { + try { + killFn(-child.pid, signal); + return; + } catch { + // Fall through to child.kill when the group is already gone. + } + } + signalChild(signal); + }; + + signalTree("SIGTERM"); + + if (graceMs <= 0 || !isAlive()) return; + await waitForExitOrTimeout(opts.waitForExit, graceMs); + if (!isAlive()) return; + + log.warn("rewrite child ignored SIGTERM; escalating to SIGKILL", { + graceMs, + }); + signalTree("SIGKILL"); +} + +/** + * @param {Promise | undefined} exited + * @param {number} graceMs + */ +function waitForExitOrTimeout(exited, graceMs) { + return new Promise((resolve) => { + const timer = setTimeout(resolve, graceMs); + exited?.then( + () => { + clearTimeout(timer); + resolve(undefined); + }, + () => { + clearTimeout(timer); + resolve(undefined); + }, + ); + }); +} + +/** + * @param {NodeJS.ProcessEnv} [env] + */ +export function resolveAgentGuardNpmRegistry(env = process.env) { + const fromEnv = env.JFROG_AGENT_GUARD_REPO?.trim(); + return fromEnv || DEFAULT_AGENT_GUARD_NPM_REGISTRY; +} + +/** + * @param {NodeJS.ProcessEnv} [env] + */ +export function resolveAgentGuardSpec(env = process.env) { + const version = + env.JFROG_AGENT_GUARD_VERSION?.trim() || DEFAULT_AGENT_GUARD_VERSION; + return `${AGENT_GUARD_PACKAGE}@${version}`; +} + +/** + * @param {NodeJS.ProcessEnv} [env] + * @returns {string | undefined} + */ +export function resolveAgentGuardBin(env = process.env) { + return env[AGENT_GUARD_BIN_ENV]?.trim() || undefined; +} + +/** + * @param {{ + * paths: string[], + * project?: string, + * serverId?: string, + * allowRoots?: string[], + * env?: NodeJS.ProcessEnv, + * }} opts + * @returns {string[]} + * @throws {Error} when project is missing or paths are empty + */ +export function buildAgentGuardRewriteArgs(opts) { + const env = opts.env ?? process.env; + const paths = opts.paths ?? []; + if (paths.length === 0) { + throw new Error("rewrite-mcp-json requires at least one mcp.json path"); + } + const project = + opts.project?.trim() || resolveRewriteProject(env, { mcpPaths: paths }); + if (!project) { + throw new Error("rewrite-mcp-json requires --project (or JF_PROJECT)"); + } + + const args = ["--rewrite-mcp-json", ...paths, "--project", project]; + + const server = + opts.serverId !== undefined + ? opts.serverId.trim() + : resolveRewriteServerId(env, { mcpPaths: paths }); + if (server) { + args.push("--server", server); + } + + const agentGuardRegistry = env.JFROG_AGENT_GUARD_REPO?.trim(); + if (agentGuardRegistry) { + args.push("--registry", agentGuardRegistry); + } + + for (const root of opts.allowRoots ?? []) { + if (root) args.push("--allow-root", root); + } + + args.push("--format", "json"); + return args; +} + +/** + * @param {{ + * paths: string[], + * project?: string, + * serverId?: string, + * allowRoots?: string[], + * env?: NodeJS.ProcessEnv, + * }} opts + * @returns {string[]} + */ +export function buildNpxArgs(opts) { + const env = opts.env ?? process.env; + return [ + "--yes", + "--registry", + resolveAgentGuardNpmRegistry(env), + resolveAgentGuardSpec(env), + ...buildAgentGuardRewriteArgs(opts), + ]; +} + +/** + * @param {{ + * paths: string[], + * project?: string, + * serverId?: string, + * allowRoots?: string[], + * env?: NodeJS.ProcessEnv, + * platform?: NodeJS.Platform, + * }} opts + * @returns {{ command: string, args: string[], local: boolean }} + */ +export function resolveAgentGuardCommand(opts) { + const env = opts.env ?? process.env; + const platform = opts.platform ?? process.platform; + const bin = resolveAgentGuardBin(env); + if (bin) { + return { + command: bin, + args: buildAgentGuardRewriteArgs(opts), + local: true, + }; + } + return { + command: resolveNpxCommand(platform), + args: buildNpxArgs(opts), + local: false, + }; +} + +/** + * Spawn Agent Guard `--rewrite-mcp-json`. AG writes files; stdout is JSON + * summary when `--format json` is passed. + * @param {{ + * paths: string[], + * project?: string, + * serverId?: string, + * allowRoots?: string[], + * spawnFn?: typeof spawn, + * env?: NodeJS.ProcessEnv, + * timeoutMs?: number, + * graceMs?: number, + * platform?: NodeJS.Platform, + * killFn?: (pid: number, signal?: string) => true, + * }} opts + * @returns {Promise<{ code: number, stdout: string, stderr: string }>} + */ +export function runAgentGuardRewriteMcpJson(opts) { + const spawnFn = opts.spawnFn ?? spawn; + const env = opts.env ?? process.env; + const timeoutMs = + opts.timeoutMs === undefined ? DEFAULT_REWRITE_TIMEOUT_MS : opts.timeoutMs; + const platform = opts.platform ?? process.platform; + + let command; + let args; + let spawnOpts; + try { + const resolved = resolveAgentGuardCommand({ + paths: opts.paths, + project: opts.project, + serverId: opts.serverId, + allowRoots: opts.allowRoots, + env, + platform, + }); + command = resolved.command; + spawnOpts = buildNpxSpawnOptions(env, platform, { local: resolved.local }); + args = spawnOpts.shell + ? quoteSpawnArgs(resolved.args, platform) + : resolved.args; + } catch (err) { + return Promise.resolve({ + code: 1, + stdout: "", + stderr: err?.message ?? String(err), + }); + } + + return new Promise((resolve) => { + let stdout = ""; + let stderr = ""; + let settled = false; + let exited = false; + let timedOut = false; + let markExited = () => {}; + const exitedPromise = new Promise((r) => { + markExited = r; + }); + /** @type {ReturnType | undefined} */ + let timer; + const finish = (result) => { + if (settled) return; + settled = true; + if (timer !== undefined) clearTimeout(timer); + resolve(result); + }; + + let child; + try { + child = spawnFn(command, args, spawnOpts); + } catch (err) { + finish({ + code: 1, + stdout: "", + stderr: err?.message ?? String(err), + }); + return; + } + + child.stdout?.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr?.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", (err) => { + exited = true; + markExited(); + finish({ + code: 1, + stdout, + stderr: err?.message ?? String(err), + }); + }); + child.on("close", (code) => { + exited = true; + markExited(); + if (timedOut) return; + finish({ code: code ?? 1, stdout, stderr }); + }); + + child.stdin?.on?.("error", () => {}); + try { + child.stdin?.end(); + } catch { + // Child may already have exited. + } + + if (timeoutMs > 0) { + timer = setTimeout(() => { + timedOut = true; + const finishTimedOut = () => { + finish({ + code: 1, + stdout, + stderr: `${stderr ? `${stderr.trim()}\n` : ""}rewrite timed out after ${timeoutMs}ms`, + }); + }; + killRewriteChildTree(child, { + platform, + killFn: opts.killFn, + spawnFn, + graceMs: opts.graceMs, + isAlive: () => !exited, + waitForExit: exitedPromise, + }).then(finishTimedOut, finishTimedOut); + }, timeoutMs); + } + }); +} + +/** + * @param {string} raw + * @returns {{ scanned?: number, rewritten?: number, files?: string[], errors?: string[], dryRun?: boolean } | null} + */ +export function parseRewriteMcpJsonResult(raw) { + if (typeof raw !== "string" || !raw.trim()) return null; + try { + const parsed = JSON.parse(raw.trim()); + if ( + typeof parsed !== "object" || + parsed === null || + Array.isArray(parsed) + ) { + return null; + } + return parsed; + } catch { + return null; + } +} + +/** + * Strip userinfo from URLs before logging. + * @param {string} text + * @returns {string} + */ +export function redactUrlCredentials(text) { + return String(text ?? "").replace( + /([a-z][a-z0-9+.-]*:\/\/)[^/\s@]+@/gi, + "$1***@", + ); +} + +/** + * Soft-fail pipeline result. `code` is always 0 so harness hooks never break + * the session; `rewritten` is the Agent Guard summary count (0 on skips). + * @typedef {{ code: 0, rewritten: number }} RewritePipelineResult + */ + +/** @returns {RewritePipelineResult} */ +function pipelineOk(rewritten = 0) { + return { code: 0, rewritten: Number(rewritten) > 0 ? Number(rewritten) : 0 }; +} + +/** + * Orchestration: kill switch → discover → project/server → Step 0 gate → + * rewrite. Server id is resolved once and reused for both the gate and AG + * `--server`. Always soft-fails (`code: 0`). Harness adapters supply discovery + * + allow-roots. + * + * @param {{ + * discover: () => string[] | Promise, + * allowRoots?: string[] | ((paths: string[]) => string[]), + * env?: NodeJS.ProcessEnv, + * spawnFn?: typeof spawn, + * timeoutMs?: number, + * graceMs?: number, + * platform?: NodeJS.Platform, + * killFn?: (pid: number, signal?: string) => true, + * runAgentGuardCheckFn?: typeof runAgentGuardCheck, + * readFileSyncFn?: typeof readFileSync, + * serverIdHint?: string, + * }} opts + * @returns {Promise} + */ +export async function runRewriteMcpJsonPipeline(opts) { + const env = opts.env ?? process.env; + const checkFn = opts.runAgentGuardCheckFn ?? runAgentGuardCheck; + + if (isRewriteDisabled(env)) { + log.info("rewrite disabled via env", { env: DISABLE_ENV }); + return pipelineOk(); + } + + let paths; + try { + paths = await opts.discover(); + } catch (err) { + log.error("discover failed; soft no-op", { + error: err?.message ?? String(err), + }); + return pipelineOk(); + } + + if (!Array.isArray(paths) || paths.length === 0) { + log.info("no mcp.json files found; skip rewrite"); + return pipelineOk(); + } + + const project = resolveRewriteProject(env, { + mcpPaths: paths, + readFileSyncFn: opts.readFileSyncFn, + }); + if (!project) { + log.info("rewrite skipped; missing JF_PROJECT", {}); + return pipelineOk(); + } + + const serverId = resolveRewriteServerId(env, { + mcpPaths: paths, + readFileSyncFn: opts.readFileSyncFn, + serverIdHint: opts.serverIdHint, + }); + + const gate = await checkFn({ + serverId: serverId || undefined, + env, + }); + if (gate.code !== EXIT_ENABLED) { + log.info("agent-guard check blocked rewrite; soft no-op", { + code: gate.code, + reason: gate.reason, + }); + return pipelineOk(); + } + + const allowRoots = + typeof opts.allowRoots === "function" + ? opts.allowRoots(paths) + : (opts.allowRoots ?? []); + + log.info("rewrite-mcp-json targets", { + count: paths.length, + allowRoots: allowRoots.length, + }); + + const budgetMs = + opts.timeoutMs === undefined ? DEFAULT_REWRITE_TIMEOUT_MS : opts.timeoutMs; + const startedAtMs = Date.now(); + const result = await runAgentGuardRewriteMcpJson({ + paths, + project, + serverId, + allowRoots, + env, + spawnFn: opts.spawnFn, + timeoutMs: budgetMs, + graceMs: opts.graceMs, + platform: opts.platform, + killFn: opts.killFn, + }); + const durMs = Date.now() - startedAtMs; + + if (result.code !== 0) { + log.error("rewrite-mcp-json failed", { + code: result.code, + stderr: redactUrlCredentials((result.stderr || "").trim()).slice(0, 500), + durMs, + }); + return pipelineOk(); + } + + const summary = parseRewriteMcpJsonResult(result.stdout); + if (summary) { + log.info("rewrite-mcp-json ok", { + scanned: summary.scanned, + rewritten: summary.rewritten, + errors: summary.errors?.length ?? 0, + durMs, + }); + } else { + log.info("rewrite-mcp-json ok; no JSON summary", { durMs }); + } + + return pipelineOk(summary?.rewritten); +} diff --git a/plugins/jfrog/scripts/cursor-align-mcp-json.mjs b/plugins/jfrog/scripts/cursor-align-mcp-json.mjs new file mode 100644 index 0000000..bb0091d --- /dev/null +++ b/plugins/jfrog/scripts/cursor-align-mcp-json.mjs @@ -0,0 +1,155 @@ +#!/usr/bin/env node +// Copyright (c) JFrog Ltd. 2026 +// Licensed under the Apache License, Version 2.0 +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Cursor sessionStart adapter: invoke the shared Agent Guard rewrite pipeline +// with Cursor plugin mcp.json discovery. +// +// Usage: +// node cursor-align-mcp-json.mjs session-start +// node cursor-align-mcp-json.mjs file-changed # same work; reserved for a +// # future Cursor FileChanged hook +// +// Path discovery: cursor-mcp-json-discover.mjs +// Orchestration / Step 0 / spawn: modules/core/rewrite-mcp-json.mjs +// +// Kill switch: JF_AGENT_REWRITE_MCP_JSON_DISABLE=1 → no-op (exit 0). +// Never exits non-zero — a failed rewrite must not break the Cursor session. +// When rewrite updates files, this hook emits additional_context asking the +// user to open a new session so Cursor reconnects those MCPs. + +import { existsSync, readdirSync, statSync } from "node:fs"; +import process from "node:process"; + +import { isMainEntry } from "../modules/core/entry.mjs"; +import { detectHarness, parseSessionId, readStdin } from "../modules/core/io.mjs"; +import { createLogger, setLogContext } from "../modules/core/logger.mjs"; +import { runRewriteMcpJsonPipeline } from "../modules/core/rewrite-mcp-json.mjs"; +import { + discoverPluginMcpJsonPaths, + resolveRewriteAllowRoots, +} from "./cursor-mcp-json-discover.mjs"; + +const HARNESS_ID = "cursor"; +const log = createLogger("align-mcp-json"); + +/** Recommended Cursor hooks.json timeout (seconds) for the align entry. */ +export const RECOMMENDED_HOOK_TIMEOUT_SEC = 60; + +/** @type {ReadonlySet} */ +export const MODES = Object.freeze(new Set(["session-start", "file-changed"])); + +export const RECONNECT_HINT = + "JFrog Agent Guard secured your plugins' MCP servers. Open a new session to reconnect."; + +/** + * @returns {string} Cursor sessionStart stdout JSON payload + */ +export function buildReconnectPayload() { + return JSON.stringify({ additional_context: RECONNECT_HINT }); +} + +/** + * @param {string | undefined} modeArg + * @returns {boolean} + */ +export function isKnownMode(modeArg) { + return typeof modeArg === "string" && MODES.has(modeArg); +} + +/** + * Thin harness entry: detect Cursor, discover paths, run shared pipeline. + * @param {string | undefined} modeArg + * @param {{ + * env?: NodeJS.ProcessEnv, + * home?: string, + * readStdinFn?: typeof readStdin, + * runRewriteMcpJsonPipelineFn?: typeof runRewriteMcpJsonPipeline, + * writeStdout?: (s: string) => void, + * readdirSyncFn?: typeof readdirSync, + * existsSyncFn?: typeof existsSync, + * statSyncFn?: typeof statSync, + * mcpJsonPath?: string, + * timeoutMs?: number, + * graceMs?: number, + * spawnFn?: unknown, + * platform?: NodeJS.Platform, + * killFn?: (pid: number, signal?: string) => true, + * runAgentGuardCheckFn?: unknown, + * readFileSyncFn?: unknown, + * }} [deps] + * @returns {Promise} always 0 + */ +export async function runCursorAlignMcpJson(modeArg, deps = {}) { + const env = deps.env ?? process.env; + const readStdinFn = deps.readStdinFn ?? readStdin; + const pipelineFn = + deps.runRewriteMcpJsonPipelineFn ?? runRewriteMcpJsonPipeline; + const writeStdout = deps.writeStdout ?? ((s) => process.stdout.write(s)); + + const stdinRaw = await readStdinFn(); + setLogContext({ ide: HARNESS_ID, sessionId: parseSessionId(stdinRaw) }); + + const harness = detectHarness(stdinRaw); + if (harness && harness !== HARNESS_ID) { + log.info("invoked by another harness; no-op", { harness }); + return 0; + } + + if (!isKnownMode(modeArg)) { + log.warn("unknown mode; no-op", { mode: modeArg ?? "" }); + return 0; + } + + const existsFn = deps.existsSyncFn ?? existsSync; + + const result = await pipelineFn({ + env, + discover: () => { + if (deps.mcpJsonPath) { + return existsFn(deps.mcpJsonPath) ? [deps.mcpJsonPath] : []; + } + return discoverPluginMcpJsonPaths({ + home: deps.home, + env, + moduleUrl: import.meta.url, + readdirSyncFn: deps.readdirSyncFn, + existsSyncFn: existsFn, + statSyncFn: deps.statSyncFn, + }); + }, + allowRoots: (paths) => + resolveRewriteAllowRoots({ + home: deps.home, + env, + moduleUrl: import.meta.url, + targets: paths, + }), + spawnFn: deps.spawnFn, + timeoutMs: deps.timeoutMs, + graceMs: deps.graceMs, + platform: deps.platform, + killFn: deps.killFn, + runAgentGuardCheckFn: deps.runAgentGuardCheckFn, + readFileSyncFn: deps.readFileSyncFn, + }); + + if ((result?.rewritten ?? 0) > 0) { + writeStdout(buildReconnectPayload()); + } + + return 0; +} + +async function main() { + await runCursorAlignMcpJson(process.argv[2]); + process.exit(0); +} + +if (isMainEntry(import.meta.url)) { + main().catch((err) => { + log.error("unexpected failure", { error: err?.message ?? String(err) }); + process.exit(0); + }); +} diff --git a/plugins/jfrog/scripts/cursor-align-mcp-json.test.mjs b/plugins/jfrog/scripts/cursor-align-mcp-json.test.mjs new file mode 100644 index 0000000..e059029 --- /dev/null +++ b/plugins/jfrog/scripts/cursor-align-mcp-json.test.mjs @@ -0,0 +1,210 @@ +// Copyright (c) JFrog Ltd. 2026 +// Licensed under the Apache License, Version 2.0 +// https://www.apache.org/licenses/LICENSE-2.0 + +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { ROOTS_ENV } from "./cursor-mcp-json-discover.mjs"; +import { + RECONNECT_HINT, + buildReconnectPayload, + isKnownMode, + RECOMMENDED_HOOK_TIMEOUT_SEC, + runCursorAlignMcpJson, +} from "./cursor-align-mcp-json.mjs"; +import { + DEFAULT_KILL_GRACE_MS, + DEFAULT_REWRITE_TIMEOUT_MS, +} from "../modules/core/rewrite-mcp-json.mjs"; + +/** + * @param {string[]} segments + * @returns {string} + */ +function tempDir(...segments) { + const root = mkdtempSync(path.join(tmpdir(), "cursor-align-")); + const full = path.join(root, ...segments); + mkdirSync(full, { recursive: true }); + return full; +} + +test("hooks.json align timeout matches RECOMMENDED_HOOK_TIMEOUT_SEC with rewrite headroom", () => { + const hooksPath = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "hooks", + "hooks.json", + ); + const hooks = JSON.parse(readFileSync(hooksPath, "utf8")); + const alignHook = hooks.hooks?.sessionStart?.find((h) => + String(h.command ?? "").includes("cursor-align-mcp-json"), + ); + assert.ok(alignHook, "sessionStart align hook missing from hooks.json"); + assert.equal(alignHook.timeout, RECOMMENDED_HOOK_TIMEOUT_SEC); + + // Gate (~7s) + rewrite spawn + SIGKILL grace must fit under the hook timeout. + const reservedOverheadMs = 7_000; + assert.ok( + DEFAULT_REWRITE_TIMEOUT_MS + DEFAULT_KILL_GRACE_MS + reservedOverheadMs < + RECOMMENDED_HOOK_TIMEOUT_SEC * 1000, + "rewrite budget + grace + gate overhead must leave margin under hooks.json timeout", + ); +}); + +test("isKnownMode accepts session-start and file-changed", () => { + assert.equal(isKnownMode("session-start"), true); + assert.equal(isKnownMode("file-changed"), true); + assert.equal(isKnownMode("other"), false); + assert.equal(isKnownMode(undefined), false); +}); + +test("buildReconnectPayload uses additional_context with approved wording", () => { + const payload = JSON.parse(buildReconnectPayload()); + assert.equal(payload.additional_context, RECONNECT_HINT); + assert.match( + payload.additional_context, + /JFrog Agent Guard secured your plugins' MCP servers/, + ); + assert.match(payload.additional_context, /Open a new session to reconnect/); + assert.equal(payload.hookSpecificOutput, undefined); +}); + +test("runCursorAlignMcpJson no-ops on unknown mode", async () => { + let called = false; + let stdout = ""; + const code = await runCursorAlignMcpJson("nope", { + readStdinFn: async () => "", + runRewriteMcpJsonPipelineFn: async () => { + called = true; + return { code: 0, rewritten: 0 }; + }, + writeStdout: (s) => { + stdout += s; + }, + }); + assert.equal(code, 0); + assert.equal(called, false); + assert.equal(stdout, ""); +}); + +test("runCursorAlignMcpJson no-ops when harness is not cursor", async () => { + let called = false; + let stdout = ""; + const code = await runCursorAlignMcpJson("session-start", { + readStdinFn: async () => + JSON.stringify({ + session_id: "s1", + hook_event_name: "SessionStart", + source: "startup", + }), + runRewriteMcpJsonPipelineFn: async () => { + called = true; + return { code: 0, rewritten: 0 }; + }, + writeStdout: (s) => { + stdout += s; + }, + }); + assert.equal(code, 0); + assert.equal(called, false); + assert.equal(stdout, ""); +}); + +test("runCursorAlignMcpJson passes discovered paths to shared pipeline", async () => { + const home = tempDir("home-pipeline"); + const cursorDir = path.join(home, ".cursor"); + const pluginA = path.join(cursorDir, "plugins", "local", "a"); + mkdirSync(pluginA, { recursive: true }); + const mcpPath = path.join(pluginA, "mcp.json"); + writeFileSync(mcpPath, "{}"); + + /** @type {{ paths?: string[], allowRoots?: string[] }} */ + const captured = {}; + let stdout = ""; + const code = await runCursorAlignMcpJson("session-start", { + home, + env: { + // Avoid scanning the real hosting plugin tree in this unit test. + [ROOTS_ENV]: pluginA, + }, + readStdinFn: async () => + JSON.stringify({ session_id: "s1", cursor_version: "1.0.0" }), + runRewriteMcpJsonPipelineFn: async (opts) => { + const paths = await opts.discover(); + captured.paths = paths; + captured.allowRoots = + typeof opts.allowRoots === "function" + ? opts.allowRoots(paths) + : opts.allowRoots; + return { code: 0, rewritten: 0 }; + }, + writeStdout: (s) => { + stdout += s; + }, + }); + + assert.equal(code, 0); + assert.deepEqual(captured.paths, [mcpPath]); + assert.ok(captured.allowRoots?.includes(cursorDir)); + assert.ok(captured.allowRoots?.includes(pluginA)); + assert.equal(stdout, ""); +}); + +test("runCursorAlignMcpJson respects mcpJsonPath override", async () => { + const file = path.join(tempDir("single"), "mcp.json"); + writeFileSync(file, "{}"); + + /** @type {string[] | undefined} */ + let paths; + const code = await runCursorAlignMcpJson("session-start", { + mcpJsonPath: file, + readStdinFn: async () => "", + runRewriteMcpJsonPipelineFn: async (opts) => { + paths = await opts.discover(); + return { code: 0, rewritten: 0 }; + }, + writeStdout: () => {}, + }); + assert.equal(code, 0); + assert.deepEqual(paths, [file]); +}); + +test("runCursorAlignMcpJson emits reconnect hint when rewritten > 0", async () => { + let stdout = ""; + const code = await runCursorAlignMcpJson("session-start", { + readStdinFn: async () => + JSON.stringify({ session_id: "s1", cursor_version: "1.0.0" }), + runRewriteMcpJsonPipelineFn: async () => ({ code: 0, rewritten: 1 }), + writeStdout: (s) => { + stdout += s; + }, + }); + assert.equal(code, 0); + const payload = JSON.parse(stdout); + assert.equal(payload.additional_context, RECONNECT_HINT); + assert.match( + payload.additional_context, + /JFrog Agent Guard secured your plugins' MCP servers/, + ); + assert.match(payload.additional_context, /Open a new session to reconnect/); + assert.doesNotMatch(payload.additional_context, /\/reload-plugins/); +}); + +test("runCursorAlignMcpJson does not emit when rewritten is 0", async () => { + let stdout = ""; + const code = await runCursorAlignMcpJson("session-start", { + readStdinFn: async () => + JSON.stringify({ session_id: "s1", cursor_version: "1.0.0" }), + runRewriteMcpJsonPipelineFn: async () => ({ code: 0, rewritten: 0 }), + writeStdout: (s) => { + stdout += s; + }, + }); + assert.equal(code, 0); + assert.equal(stdout, ""); +}); diff --git a/plugins/jfrog/scripts/cursor-mcp-json-discover.mjs b/plugins/jfrog/scripts/cursor-mcp-json-discover.mjs new file mode 100644 index 0000000..3590a14 --- /dev/null +++ b/plugins/jfrog/scripts/cursor-mcp-json-discover.mjs @@ -0,0 +1,311 @@ +// Copyright (c) JFrog Ltd. 2026 +// Licensed under the Apache License, Version 2.0 +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Cursor-specific discovery of plugin mcp.json / .mcp.json paths and Agent Guard +// --allow-root directories. Harness entry lives in cursor-align-mcp-json.mjs; +// shared rewrite orchestration lives in modules/core/rewrite-mcp-json.mjs. +// +// Override roots: JF_ALIGN_MCP_JSON_ROOTS=/path/a:/path/b +// (POSIX: colon/comma; Windows: semicolon/comma — avoids splitting C:\…) +// Default discovery root: $HOME/.cursor (Cursor loads plugins from here; +// CURSOR_CONFIG_DIR is ignored — Cursor does not relocate plugins via that var) +// Marketplace cache (~/.cursor/plugins/cache) is scanned by default; +// skip with JF_ALIGN_MCP_JSON_SKIP_CACHE=1 + +import { existsSync, readdirSync, realpathSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +/** + * OS-delimiter- or comma-separated absolute plugin roots; skips default + * discovery. POSIX uses `:` / `,`; Windows uses `;` / `,` (not `:` — that + * would split drive letters like `C:\…`). + */ +export const ROOTS_ENV = "JF_ALIGN_MCP_JSON_ROOTS"; +/** When "1", skip ~/.cursor/plugins/cache (marketplace installs; on by default). */ +export const SKIP_CACHE_ENV = "JF_ALIGN_MCP_JSON_SKIP_CACHE"; + +/** + * Plugin root is the parent of `scripts/` (where this file lives). + * @param {string} [moduleUrl] — import.meta.url of a scripts/*.mjs module + */ +export function resolvePluginRoot(moduleUrl = import.meta.url) { + const scriptsDir = path.dirname(fileURLToPath(moduleUrl)); + return path.dirname(scriptsDir); +} + +/** + * @param {string} [moduleUrl] + */ +export function resolvePluginMcpJsonPath(moduleUrl = import.meta.url) { + return path.join(resolvePluginRoot(moduleUrl), "mcp.json"); +} + +/** + * @param {string} raw + * @param {NodeJS.Platform} [platform] + * @returns {string[]} + */ +export function parseRootsEnv(raw, platform = process.platform) { + if (typeof raw !== "string" || !raw.trim()) return []; + // Use the *requested* platform delimiter — not path.delimiter from the host + // OS — so unit tests and cross-compiled callers get correct splitting. + // Windows: `;` / `,` (never bare `:` — that splits drive letters like `C:\…`). + // POSIX: `:` / `,`. + const sep = platform === "win32" ? /[;,]/ : /[:,]/; + return raw + .split(sep) + .map((s) => s.trim()) + .filter(Boolean); +} + +/** + * Cursor plugin config root: `$HOME/.cursor`. + * Cursor loads plugins from this path; `CURSOR_CONFIG_DIR` (CLI config) is not + * used for plugin discovery. + * @param {{ + * home?: string, + * }} [opts] + * @returns {string} + */ +export function resolveCursorConfigDir(opts = {}) { + const home = opts.home ?? homedir(); + return path.join(home, ".cursor"); +} + +/** + * True when resolvedPath is cursorDir or a descendant (after realpath). + * @param {string} resolvedPath + * @param {string} cursorDirResolved + */ +export function isPathInsideResolvedRoot(resolvedPath, cursorDirResolved) { + const root = cursorDirResolved.endsWith(path.sep) + ? cursorDirResolved + : cursorDirResolved + path.sep; + return ( + resolvedPath === cursorDirResolved || resolvedPath.startsWith(root) + ); +} + +/** + * @param {{ + * home?: string, + * env?: NodeJS.ProcessEnv, + * readdirSyncFn?: typeof readdirSync, + * existsSyncFn?: typeof existsSync, + * statSyncFn?: typeof statSync, + * realpathSyncFn?: typeof realpathSync, + * }} [opts] + * @returns {string[]} + */ +export function discoverCursorPluginRoots(opts = {}) { + const env = opts.env ?? process.env; + const home = opts.home ?? homedir(); + const readdirFn = opts.readdirSyncFn ?? readdirSync; + const existsFn = opts.existsSyncFn ?? existsSync; + const statFn = opts.statSyncFn ?? statSync; + const realpathFn = opts.realpathSyncFn ?? realpathSync; + + const fromEnv = parseRootsEnv(env[ROOTS_ENV] ?? ""); + if (fromEnv.length > 0) { + // Override roots are trusted and not confined to ~/.cursor. + return fromEnv.filter((root) => { + try { + return existsFn(root) && statFn(root).isDirectory(); + } catch { + return false; + } + }); + } + + const cursorDir = resolveCursorConfigDir({ home }); + let cursorDirResolved; + try { + cursorDirResolved = realpathFn(cursorDir); + } catch { + return []; + } + + /** @type {string[]} */ + const roots = []; + const localDir = path.join(cursorDir, "plugins", "local"); + roots.push( + ...listImmediateSubdirs(localDir, { readdirFn, existsFn, statFn }), + ); + + if (env[SKIP_CACHE_ENV] !== "1") { + const cacheRoot = path.join(cursorDir, "plugins", "cache"); + for (const marketplace of listImmediateSubdirs(cacheRoot, { + readdirFn, + existsFn, + statFn, + })) { + for (const pluginName of listImmediateSubdirs(marketplace, { + readdirFn, + existsFn, + statFn, + })) { + roots.push( + ...listImmediateSubdirs(pluginName, { readdirFn, existsFn, statFn }), + ); + } + } + } + + return roots.filter((root) => { + try { + const resolved = realpathFn(root); + return ( + statFn(resolved).isDirectory() && + isPathInsideResolvedRoot(resolved, cursorDirResolved) + ); + } catch { + return false; + } + }); +} + +/** + * @param {string} dir + * @param {{ + * readdirFn: typeof readdirSync, + * existsFn: typeof existsSync, + * statFn: typeof statSync, + * }} fs + * @returns {string[]} + */ +function listImmediateSubdirs(dir, fs) { + if (!fs.existsFn(dir)) return []; + let names; + try { + names = fs.readdirFn(dir); + } catch { + return []; + } + /** @type {string[]} */ + const out = []; + for (const name of names) { + const full = path.join(dir, name); + try { + if (fs.statFn(full).isDirectory()) out.push(full); + } catch { + // skip + } + } + return out; +} + +/** + * Resolve MCP config paths for a plugin root. Cursor loads servers from both + * `mcp.json` and `.mcp.json` when present, so both are returned (in that order). + * @param {string} pluginRoot + * @param {{ + * existsSyncFn?: typeof existsSync, + * }} [deps] + * @returns {string[]} + */ +export function resolveMcpJsonForPluginRoot(pluginRoot, deps = {}) { + const existsFn = deps.existsSyncFn ?? existsSync; + /** @type {string[]} */ + const paths = []; + for (const name of ["mcp.json", ".mcp.json"]) { + const candidate = path.join(pluginRoot, name); + if (existsFn(candidate)) paths.push(candidate); + } + return paths; +} + +/** + * @param {{ + * home?: string, + * env?: NodeJS.ProcessEnv, + * moduleUrl?: string, + * includeSelf?: boolean, + * readdirSyncFn?: typeof readdirSync, + * existsSyncFn?: typeof existsSync, + * statSyncFn?: typeof statSync, + * realpathSyncFn?: typeof realpathSync, + * }} [opts] + * @returns {string[]} + */ +export function discoverPluginMcpJsonPaths(opts = {}) { + const env = opts.env ?? process.env; + const existsFn = opts.existsSyncFn ?? existsSync; + const roots = discoverCursorPluginRoots({ + home: opts.home, + env, + readdirSyncFn: opts.readdirSyncFn, + existsSyncFn: existsFn, + statSyncFn: opts.statSyncFn, + realpathSyncFn: opts.realpathSyncFn, + }); + + /** @type {string[]} */ + const paths = []; + const seen = new Set(); + + const add = (p) => { + if (!p || seen.has(p)) return; + seen.add(p); + paths.push(p); + }; + + for (const root of roots) { + for (const p of resolveMcpJsonForPluginRoot(root, { + existsSyncFn: existsFn, + })) { + add(p); + } + } + + const rootsOverridden = parseRootsEnv(env[ROOTS_ENV] ?? "").length > 0; + if (opts.includeSelf !== false && !rootsOverridden) { + for (const p of resolveMcpJsonForPluginRoot( + resolvePluginRoot(opts.moduleUrl), + { + existsSyncFn: existsFn, + }, + )) { + add(p); + } + } + + return paths; +} + +/** + * Allow-roots for Agent Guard: ~/.cursor, override roots, plugin root, + * and parent dirs of discovered targets. + * @param {{ + * home?: string, + * env?: NodeJS.ProcessEnv, + * moduleUrl?: string, + * targets?: string[], + * }} [opts] + * @returns {string[]} + */ +export function resolveRewriteAllowRoots(opts = {}) { + const env = opts.env ?? process.env; + const home = opts.home ?? homedir(); + /** @type {string[]} */ + const roots = []; + const seen = new Set(); + const add = (p) => { + if (!p || seen.has(p)) return; + seen.add(p); + roots.push(p); + }; + + add(resolveCursorConfigDir({ home })); + for (const root of parseRootsEnv(env[ROOTS_ENV] ?? "")) { + add(root); + } + add(resolvePluginRoot(opts.moduleUrl)); + for (const target of opts.targets ?? []) { + add(path.dirname(target)); + } + return roots; +} diff --git a/plugins/jfrog/scripts/cursor-mcp-json-discover.test.mjs b/plugins/jfrog/scripts/cursor-mcp-json-discover.test.mjs new file mode 100644 index 0000000..37d8714 --- /dev/null +++ b/plugins/jfrog/scripts/cursor-mcp-json-discover.test.mjs @@ -0,0 +1,265 @@ +// Copyright (c) JFrog Ltd. 2026 +// Licensed under the Apache License, Version 2.0 +// https://www.apache.org/licenses/LICENSE-2.0 + +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { pathToFileURL } from "node:url"; + +import { + ROOTS_ENV, + SKIP_CACHE_ENV, + discoverCursorPluginRoots, + discoverPluginMcpJsonPaths, + parseRootsEnv, + resolveCursorConfigDir, + resolveMcpJsonForPluginRoot, + resolvePluginRoot, + resolveRewriteAllowRoots, +} from "./cursor-mcp-json-discover.mjs"; + +/** + * @param {string[]} segments + * @returns {string} + */ +function tempDir(...segments) { + const root = mkdtempSync(path.join(tmpdir(), "cursor-discover-")); + const full = path.join(root, ...segments); + mkdirSync(full, { recursive: true }); + return full; +} + +/** + * Fake $HOME with a `.cursor` tree for discovery tests. + * @param {string[]} underCursor — path segments under `.cursor` + * @returns {{ home: string, cursorDir: string, path: string }} + */ +function tempHomeCursor(...underCursor) { + const home = mkdtempSync(path.join(tmpdir(), "cursor-home-")); + const cursorDir = path.join(home, ".cursor"); + const full = path.join(cursorDir, ...underCursor); + mkdirSync(full, { recursive: true }); + return { home, cursorDir, path: full }; +} + +test("parseRootsEnv splits POSIX and Windows delimiters", () => { + assert.deepEqual(parseRootsEnv("/a:/b,/c", "linux"), ["/a", "/b", "/c"]); + assert.deepEqual(parseRootsEnv("C:\\a;D:\\b,E:\\c", "win32"), [ + "C:\\a", + "D:\\b", + "E:\\c", + ]); + // Bare colon must not split a Windows drive letter. + assert.deepEqual(parseRootsEnv("C:\\plugins\\local", "win32"), [ + "C:\\plugins\\local", + ]); + assert.deepEqual(parseRootsEnv(" ", "linux"), []); +}); + +test("resolveCursorConfigDir is always $HOME/.cursor", () => { + const home = "/home/user"; + assert.equal( + resolveCursorConfigDir({ home }), + path.join(home, ".cursor"), + ); +}); + +test("discoverCursorPluginRoots ignores CURSOR_CONFIG_DIR", () => { + const { home, path: localPlugin } = tempHomeCursor( + "plugins", + "local", + "from-home", + ); + const customCursor = tempDir("custom-cursor"); + mkdirSync( + path.join(customCursor, "plugins", "local", "from-env"), + { recursive: true }, + ); + + const roots = discoverCursorPluginRoots({ + home, + env: { CURSOR_CONFIG_DIR: customCursor }, + }); + assert.deepEqual(roots, [localPlugin]); +}); + +test("resolvePluginRoot is parent of scripts/", () => { + const scriptsDir = path.join("/tmp/plugin", "scripts"); + const moduleUrl = pathToFileURL( + path.join(scriptsDir, "cursor-mcp-json-discover.mjs"), + ).href; + assert.equal(resolvePluginRoot(moduleUrl), path.join("/tmp/plugin")); +}); + +test("resolveMcpJsonForPluginRoot finds mcp.json and .mcp.json", () => { + const root = tempDir("plugin-a"); + writeFileSync(path.join(root, ".mcp.json"), "{}"); + assert.deepEqual(resolveMcpJsonForPluginRoot(root), [ + path.join(root, ".mcp.json"), + ]); + writeFileSync(path.join(root, "mcp.json"), "{}"); + assert.deepEqual(resolveMcpJsonForPluginRoot(root), [ + path.join(root, "mcp.json"), + path.join(root, ".mcp.json"), + ]); +}); + +test("discoverCursorPluginRoots scans plugins/local", () => { + const { home, cursorDir } = tempHomeCursor("plugins", "local"); + const localA = path.join(cursorDir, "plugins", "local", "alpha"); + const localB = path.join(cursorDir, "plugins", "local", "beta"); + mkdirSync(localA, { recursive: true }); + mkdirSync(localB, { recursive: true }); + + const roots = discoverCursorPluginRoots({ + home, + env: {}, + }); + assert.deepEqual(roots.sort(), [localA, localB].sort()); +}); + +test("discoverCursorPluginRoots honors JF_ALIGN_MCP_JSON_ROOTS override", () => { + const override = tempDir("override-root"); + const roots = discoverCursorPluginRoots({ + home: "/unused", + env: { [ROOTS_ENV]: override }, + }); + assert.deepEqual(roots, [override]); +}); + +test("discoverCursorPluginRoots includes cache tree by default", () => { + const { home, cursorDir } = tempHomeCursor("plugins", "cache"); + const versionRoot = path.join( + cursorDir, + "plugins", + "cache", + "marketplace", + "plugin-name", + "1.0.0", + ); + mkdirSync(versionRoot, { recursive: true }); + + const withCache = discoverCursorPluginRoots({ + home, + env: {}, + }); + assert.deepEqual(withCache, [versionRoot]); + + const skipped = discoverCursorPluginRoots({ + home, + env: { + [SKIP_CACHE_ENV]: "1", + }, + }); + assert.deepEqual(skipped, []); +}); + +test("discoverPluginMcpJsonPaths finds mcp.json and .mcp.json and includes self", () => { + const { home, cursorDir } = tempHomeCursor("plugins", "local"); + const pluginA = path.join(cursorDir, "plugins", "local", "a"); + const pluginB = path.join(cursorDir, "plugins", "local", "b"); + const pluginC = path.join(cursorDir, "plugins", "local", "c"); + mkdirSync(pluginA, { recursive: true }); + mkdirSync(pluginB, { recursive: true }); + mkdirSync(pluginC, { recursive: true }); + writeFileSync(path.join(pluginA, "mcp.json"), "{}"); + writeFileSync(path.join(pluginB, ".mcp.json"), "{}"); + writeFileSync(path.join(pluginC, "mcp.json"), "{}"); + writeFileSync(path.join(pluginC, ".mcp.json"), "{}"); + + const selfRoot = tempDir("self-plugin"); + writeFileSync(path.join(selfRoot, "mcp.json"), "{}"); + writeFileSync(path.join(selfRoot, ".mcp.json"), "{}"); + const moduleUrl = pathToFileURL( + path.join(selfRoot, "scripts", "cursor-mcp-json-discover.mjs"), + ).href; + + const paths = discoverPluginMcpJsonPaths({ + home, + env: {}, + moduleUrl, + }); + + assert.deepEqual( + paths.sort(), + [ + path.join(pluginA, "mcp.json"), + path.join(pluginB, ".mcp.json"), + path.join(pluginC, "mcp.json"), + path.join(pluginC, ".mcp.json"), + path.join(selfRoot, "mcp.json"), + path.join(selfRoot, ".mcp.json"), + ].sort(), + ); +}); + +test("discoverPluginMcpJsonPaths skips self when roots env overrides", () => { + const override = tempDir("override-only"); + writeFileSync(path.join(override, "mcp.json"), "{}"); + const selfRoot = tempDir("self-skipped"); + writeFileSync(path.join(selfRoot, "mcp.json"), "{}"); + const moduleUrl = pathToFileURL( + path.join(selfRoot, "scripts", "cursor-mcp-json-discover.mjs"), + ).href; + + const paths = discoverPluginMcpJsonPaths({ + env: { [ROOTS_ENV]: override }, + moduleUrl, + }); + assert.deepEqual(paths, [path.join(override, "mcp.json")]); +}); + +test("discoverCursorPluginRoots drops symlinks that escape ~/.cursor", () => { + const { home, cursorDir } = tempHomeCursor("plugins", "local"); + const outside = tempDir("outside-plugin"); + const localDir = path.join(cursorDir, "plugins", "local"); + const safe = path.join(localDir, "safe"); + mkdirSync(safe, { recursive: true }); + const evil = path.join(localDir, "evil-link"); + symlinkSync(outside, evil); + + const roots = discoverCursorPluginRoots({ + home, + env: {}, + }); + assert.deepEqual(roots, [safe]); +}); + +test("discoverCursorPluginRoots override roots are not confined to ~/.cursor", () => { + const override = tempDir("override-outside"); + const roots = discoverCursorPluginRoots({ + home: "/unused", + env: { [ROOTS_ENV]: override }, + }); + assert.deepEqual(roots, [override]); +}); + +test("resolveRewriteAllowRoots includes ~/.cursor, overrides, plugin, targets", () => { + const home = "/tmp/fake-home"; + const cursorDir = path.join(home, ".cursor"); + const override = "/tmp/override"; + const selfRoot = "/tmp/self-plugin"; + const moduleUrl = pathToFileURL( + path.join(selfRoot, "scripts", "cursor-mcp-json-discover.mjs"), + ).href; + const target = "/tmp/other-plugin/mcp.json"; + + const roots = resolveRewriteAllowRoots({ + home, + env: { + CURSOR_CONFIG_DIR: "/tmp/ignored-cursor-cfg", + [ROOTS_ENV]: override, + }, + moduleUrl, + targets: [target], + }); + assert.deepEqual(roots, [ + cursorDir, + override, + selfRoot, + "/tmp/other-plugin", + ]); +});