diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index 8bca7d9..3ddf459 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -6,7 +6,7 @@ }, "metadata": { "description": "JFrog Platform plugins for Cursor", - "version": "0.5.17", + "version": "0.5.18", "pluginRoot": "plugins" }, "plugins": [ 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..6edcfe5 --- /dev/null +++ b/.github/scripts/sync-modules.test.mjs @@ -0,0 +1,31 @@ +// 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..11dd963 100644 --- a/VENDOR.md +++ b/VENDOR.md @@ -38,10 +38,12 @@ 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`). + ## 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` from `sync-modules-vendor.json` (today: `paths: ["modules"]`) and optional `dest_prefix` / `keep`. It copies those paths into the destination (default: repo root) and restores any `keep` files that existed before the sync. diff --git a/plugins/jfrog/.cursor-plugin/plugin.json b/plugins/jfrog/.cursor-plugin/plugin.json index c055aa4..60a1354 100644 --- a/plugins/jfrog/.cursor-plugin/plugin.json +++ b/plugins/jfrog/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "jfrog", "displayName": "JFrog Platform", - "version": "0.5.17", + "version": "0.5.18", "description": "JFrog Platform integration with MCP, security skills, Agent Package Resolution, supply-chain best practices, and JFrog Agent Guard governance for adding, removing, and listing MCP servers.", "author": { "name": "JFrog", diff --git a/plugins/jfrog/README.md b/plugins/jfrog/README.md index e8ac892..f32cf8e 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. The shipped template enables it with emp - **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/scripts/cursor-align-mcp-json.mjs b/plugins/jfrog/scripts/cursor-align-mcp-json.mjs new file mode 100644 index 0000000..2c29837 --- /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?.outcome === "rewritten") { + 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..695efc6 --- /dev/null +++ b/plugins/jfrog/scripts/cursor-align-mcp-json.test.mjs @@ -0,0 +1,218 @@ +// 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 { exitCode: 0, outcome: "skipped_current", reason: "" }; + }, + 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 { exitCode: 0, outcome: "skipped_current", reason: "" }; + }, + 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 { exitCode: 0, outcome: "skipped_current", reason: "" }; + }, + 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 { exitCode: 0, outcome: "skipped_current", reason: "" }; + }, + writeStdout: () => {}, + }); + assert.equal(code, 0); + assert.deepEqual(paths, [file]); +}); + +test("runCursorAlignMcpJson emits reconnect hint when outcome is rewritten", async () => { + let stdout = ""; + const code = await runCursorAlignMcpJson("session-start", { + readStdinFn: async () => + JSON.stringify({ session_id: "s1", cursor_version: "1.0.0" }), + runRewriteMcpJsonPipelineFn: async () => ({ + exitCode: 0, + outcome: "rewritten", + reason: "", + }), + 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 outcome is not rewritten", async () => { + let stdout = ""; + const code = await runCursorAlignMcpJson("session-start", { + readStdinFn: async () => + JSON.stringify({ session_id: "s1", cursor_version: "1.0.0" }), + runRewriteMcpJsonPipelineFn: async () => ({ + exitCode: 0, + outcome: "skipped_current", + reason: "", + }), + 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..774879b --- /dev/null +++ b/plugins/jfrog/scripts/cursor-mcp-json-discover.mjs @@ -0,0 +1,332 @@ +// 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). + * File symlinks are resolved; a path that is not inside the plugin root is + * excluded (e.g. `mcp.json` → `~/.cursor/mcp.json`). + * @param {string} pluginRoot + * @param {{ + * existsSyncFn?: typeof existsSync, + * realpathSyncFn?: typeof realpathSync, + * }} [deps] + * @returns {string[]} + */ +export function resolveMcpJsonForPluginRoot(pluginRoot, deps = {}) { + const existsFn = deps.existsSyncFn ?? existsSync; + const realpathFn = deps.realpathSyncFn ?? realpathSync; + /** @type {string[]} */ + const paths = []; + let pluginRootResolved; + try { + pluginRootResolved = realpathFn(pluginRoot); + } catch { + return paths; + } + for (const name of ["mcp.json", ".mcp.json"]) { + const candidate = path.join(pluginRoot, name); + if (!existsFn(candidate)) continue; + try { + const resolved = realpathFn(candidate); + if (isPathInsideResolvedRoot(resolved, pluginRootResolved)) { + paths.push(candidate); + } + } catch { + // skip unreadable / broken symlink + } + } + 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 realpathFn = opts.realpathSyncFn ?? realpathSync; + const roots = discoverCursorPluginRoots({ + home: opts.home, + env, + readdirSyncFn: opts.readdirSyncFn, + existsSyncFn: existsFn, + statSyncFn: opts.statSyncFn, + realpathSyncFn: realpathFn, + }); + + /** @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, + realpathSyncFn: realpathFn, + })) { + 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, + realpathSyncFn: realpathFn, + }, + )) { + 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..9dcbe40 --- /dev/null +++ b/plugins/jfrog/scripts/cursor-mcp-json-discover.test.mjs @@ -0,0 +1,277 @@ +// 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("resolveMcpJsonForPluginRoot drops mcp.json symlink outside plugin root", () => { + const root = tempDir("plugin-symlink"); + const outside = tempDir("outside-mcp"); + const target = path.join(outside, "mcp.json"); + writeFileSync(target, "{}"); + symlinkSync(target, path.join(root, "mcp.json")); + writeFileSync(path.join(root, ".mcp.json"), "{}"); + assert.deepEqual(resolveMcpJsonForPluginRoot(root), [ + path.join(root, ".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", + ]); +});