diff --git a/.changeset/honest-cursors-validate.md b/.changeset/honest-cursors-validate.md new file mode 100644 index 000000000..fafeff43d --- /dev/null +++ b/.changeset/honest-cursors-validate.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": minor +--- + +Add honest Cursor plugin host validation that probes the installed CLI for version evidence while always validating generated bytes locally against the vendored pinned schemas and loader contract. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 788e5c473..59779c9ed 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -34,6 +34,15 @@ gate a build, a validation, or a dev rebuild. | `AB8xxx` | Development server configuration. | | `AB9xxx` | Eval selection, harnesses, and persisted runs. | +## Cursor built-artifact validation (`AB6026`–`AB6029`) + +| Code | Severity | Trigger | Recovery | +| --- | --- | --- | --- | +| `AB6026` | info | Every Cursor host-validation report states that Cursor publishes no plugin-validate devtools verb and names the vendored schema pin used for local validation. | Review the pinned Cursor schema provenance before changing the local validator contract. | +| `AB6027` | error | A required generated Cursor document is missing or a present plugin, marketplace, MCP, or hooks document is unreadable, invalid JSON, or rejected by its pinned schema. | Repair the generated Cursor JSON document so it satisfies the vendored pinned schema, then rebuild. | +| `AB6028` | error | Generated bytes violate pinned Cursor loader evidence: manifest-candidate precedence selects a fallback manifest, a symlink resolves outside the bundle, or `CURSOR_PLUGIN_ROOT` appears outside loader-substituted fields. | Repair the generated Cursor layout, token locations, or symlinks to match the pinned loader evidence, then rebuild. | +| `AB6029` | info / warning | The Cursor Agent version probe is unavailable (`ENOENT`, info) or cannot complete successfully (warning). Local pinned-schema validation still runs. | Install Cursor Agent or repair `cursor-agent --version` when local CLI version evidence is required, then rerun artifact validation. | + ## Codex host validation (`AB6030`–`AB6033`) Codex 0.147.0 publishes plugin installation commands but no plugin-validation diff --git a/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json b/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json index c116cba78..7a418c172 100644 --- a/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json +++ b/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json @@ -87,6 +87,8 @@ "Installed cursor-agent-exec loader candidates: .cursor-plugin/plugin.json, .claude-plugin/plugin.json, plugin.json.", "Installed loader substitutes CURSOR_PLUGIN_ROOT in MCP command, args, env, and cwd fields and in hook commands.", "Local-plugin symlinks are realpath checked and rejected when their targets escape ~/.cursor/plugins/local.", + "2026-09-02: /home/zack/.local/bin/cursor-agent --version exited 0 and printed exactly `2026.08.31-4057e58`.", + "2026-09-02: /home/zack/.local/bin/cursor-agent --help exited 0, listed `plugin` as `Manage plugins and plugin marketplaces`, and listed no `validate` command; `cursor-agent plugin --help` exited 0 and exposed only the `marketplace` subcommand, while `cursor-agent plugins --help` exited 0 by returning the top-level help. No plugin-validate devtools verb was exposed.", "2026-09-01: cursor/plugins@070189284e702e8a4d2e3cc8913994b204c5337a schemas/plugin.schema.json defines the commands component pointer; https://cursor.com/docs documents agent chat commands as plain Markdown prompt files in commands/ named by filename.", "2026-08-31: cursor/plugins@070189284e702e8a4d2e3cc8913994b204c5337a schemas/plugin.schema.json defines the rules component pointer; https://cursor.com/docs/plugins documents the rules component.", "2026-09-02: https://cursor.com/docs/hooks#workspaceopen documents workspaceOpen input as sessionless ({ hook_event_name, cursor_version, workspace_roots, user_email }; conversation_id/generation_id/model/session_id/transcript_path omitted) and its output pluginPaths as string[] (optional), so an empty response is legal; the generated event-route wrapper validates that envelope and projects the observation-only canonical workspace/open family to no output — the native pluginPaths return channel is deliberately not modeled and never emitted.", diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index f8403352a..14193f021 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -104,6 +104,10 @@ import { validateCodexPlugin, type CodexPluginValidationReport, } from './host-contracts/codex-plugin-validation.ts'; +import { + validateCursorPlugin, + type CursorPluginValidationReport, +} from './host-contracts/cursor-plugin-validation.ts'; import type { EvalComparison } from './eval/compare.ts'; import { EvalRunStoreError } from './eval/errors.ts'; import { @@ -169,8 +173,8 @@ export type { NativeHost, RedactedEventEnvelope, } from './host-contracts/host-contract.ts'; -export { validateClaudePlugin, validateCodexPlugin }; -export type { ClaudePluginValidationReport, CodexPluginValidationReport }; +export { validateClaudePlugin, validateCodexPlugin, validateCursorPlugin }; +export type { ClaudePluginValidationReport, CodexPluginValidationReport, CursorPluginValidationReport }; export { HookService } from './services/hook-service.ts'; export type { HookListOptions, HookSimulationOptions } from './services/hook-service.ts'; @@ -248,7 +252,11 @@ export interface ValidateOptions extends ProjectOptions { export interface ValidateResult { readonly diagnostics: readonly Diagnostic[]; - readonly hostValidation?: readonly (ClaudePluginValidationReport | CodexPluginValidationReport)[]; + readonly hostValidation?: readonly ( + | ClaudePluginValidationReport + | CodexPluginValidationReport + | CursorPluginValidationReport + )[]; readonly model?: NormalizedPlugin; } @@ -484,18 +492,24 @@ export const validate = async (options: ValidateOptions): Promise target.name === 'claude' || target.name === 'codex' || target.name === 'plugin') + .filter((target) => + target.name === 'claude' || target.name === 'codex' || target.name === 'cursor' || target.name === 'plugin') .map((target) => target.name === 'codex' ? validateCodexPlugin({ pluginDirectory: join(artifact, target.name), strict: options.strict, target: target.name, }) - : validateClaudePlugin({ - pluginDirectory: join(artifact, target.name), - strict: options.strict, - target: target.name, - }))); + : target.name === 'cursor' + ? validateCursorPlugin({ + pluginDirectory: join(artifact, target.name), + target: target.name, + }) + : validateClaudePlugin({ + pluginDirectory: join(artifact, target.name), + strict: options.strict, + target: target.name, + }))); return Object.freeze({ diagnostics: freezeDiagnostics([ ...validated.diagnostics, diff --git a/packages/agent-bundle/src/host-contracts/cursor-plugin-validation.ts b/packages/agent-bundle/src/host-contracts/cursor-plugin-validation.ts new file mode 100644 index 000000000..3fbfd6753 --- /dev/null +++ b/packages/agent-bundle/src/host-contracts/cursor-plugin-validation.ts @@ -0,0 +1,439 @@ +import { lstat, readdir, readFile, realpath } from 'node:fs/promises'; +import { dirname, join, relative, resolve } from 'node:path'; + +import { Ajv, type ErrorObject } from 'ajv/dist/ajv.js'; +import addFormats from 'ajv-formats'; + +import capabilityTable from '../adapters/capabilities/cursor-2026-08-28.json' with { type: 'json' }; +import hooksSchema from '../adapters/schemas/cursor/hooks.schema.json' with { type: 'json' }; +import marketplaceSchema from '../adapters/schemas/cursor/marketplace.schema.json' with { type: 'json' }; +import mcpSchema from '../adapters/schemas/cursor/mcp.schema.json' with { type: 'json' }; +import pluginSchema from '../adapters/schemas/cursor/plugin.schema.json' with { type: 'json' }; +import type { Diagnostic, DiagnosticSeverity } from '../core/diagnostics.ts'; +import { freezeDiagnostics } from '../core/diagnostics.ts'; +import { isErrno } from '../core/errors.ts'; +import { isInsideOrEqual } from '../core/paths.ts'; +import { + runBoundedChildProcess, + type BoundedChildProcessRequest, + type BoundedChildProcessResult, +} from './process.ts'; + +const maximumOutputBytes = 1024 * 1024; +const versionTimeoutMs = 5_000; +const cursorPluginRootToken = '${CURSOR_PLUGIN_ROOT}'; +const pinnedCursorPluginCommit = '070189284e702e8a4d2e3cc8913994b204c5337a'; +const manifestCandidates = Object.freeze([ + '.cursor-plugin/plugin.json', + '.claude-plugin/plugin.json', + 'plugin.json', +] as const); + +type CursorPluginTermination = 'output-limit' | 'timed-out'; +type CursorDiagnosticCode = 'AB6026' | 'AB6027' | 'AB6028' | 'AB6029'; +type DocumentPath = '.cursor-plugin/marketplace.json' | '.cursor-plugin/plugin.json' | 'hooks/hooks.json' | 'mcp.json'; + +export type CursorPluginValidationStatus = 'failed' | 'passed' | 'unavailable' | 'warnings'; + +export interface CursorPluginValidationReport { + readonly diagnostics: readonly Diagnostic[]; + readonly host: 'cursor'; + readonly status: CursorPluginValidationStatus; + readonly target: string; + readonly version?: string; +} + +export type CursorPluginCommandResult = BoundedChildProcessResult; + +export type CursorPluginCommandRunner = ( + request: BoundedChildProcessRequest, +) => Promise; + +export interface ValidateCursorPluginOptions { + readonly executable?: string; + readonly pluginDirectory: string; + /** Injectable proof seam. Production always uses the bounded process runner. */ + readonly run?: CursorPluginCommandRunner; + readonly target: string; +} + +interface CursorProbe { + readonly diagnostics: readonly Diagnostic[]; + readonly unavailable: boolean; + readonly version?: string; +} + +interface ParsedDocument { + readonly path: DocumentPath; + readonly value: unknown; +} + +const runCursorCommand: CursorPluginCommandRunner = (request) => runBoundedChildProcess(request, { + labels: { outputLimit: 'output-limit', timedOut: 'timed-out' }, + maxOutputBytes: maximumOutputBytes, + timeoutMs: versionTimeoutMs, + windowsHide: true, +}); + +const installFormats = addFormats as unknown as (target: Ajv) => void; +const schemaValidator = new Ajv({ allErrors: true, allowUnionTypes: true, strict: true }); +installFormats(schemaValidator); + +const documentContracts = Object.freeze([ + Object.freeze({ + path: '.cursor-plugin/marketplace.json' as const, + required: false, + validate: schemaValidator.compile(marketplaceSchema), + }), + Object.freeze({ + path: '.cursor-plugin/plugin.json' as const, + required: true, + validate: schemaValidator.compile(pluginSchema), + }), + Object.freeze({ + path: 'hooks/hooks.json' as const, + required: false, + validate: schemaValidator.compile(hooksSchema), + }), + Object.freeze({ + path: 'mcp.json' as const, + required: false, + validate: schemaValidator.compile(mcpSchema), + }), +]); + +const recoveryFor = (code: CursorDiagnosticCode, severity: DiagnosticSeverity): string => { + switch (code) { + case 'AB6026': + return 'Review the pinned Cursor schema provenance before changing the local validator contract.'; + case 'AB6027': + return 'Repair the generated Cursor JSON document so it satisfies the vendored pinned schema, then rebuild.'; + case 'AB6028': + return 'Repair the generated Cursor layout, token locations, or symlinks to match the pinned loader evidence, then rebuild.'; + case 'AB6029': + return severity === 'info' + ? 'Install Cursor Agent and ensure `cursor-agent` is on PATH if local CLI version evidence is required.' + : 'Verify `cursor-agent --version` completes successfully, then rerun artifact validation.'; + default: { + const exhaustive: never = code; + throw new Error(`Unexpected Cursor validator diagnostic code: ${String(exhaustive)}`); + } + } +}; + +const diagnostic = ( + code: CursorDiagnosticCode, + message: string, + severity: DiagnosticSeverity, + target: string, +): Diagnostic => Object.freeze({ + code, + message, + recovery: recoveryFor(code, severity), + severity, + target, +}); + +const versionFrom = (output: string): string | undefined => + /\b\d{4}\.\d{2}\.\d{2}-[0-9A-Za-z]+\b/u.exec(output)?.[0]; + +const probeCursor = async ( + executable: string, + cwd: string, + run: CursorPluginCommandRunner, + target: string, +): Promise => { + try { + const result = await run(Object.freeze({ + args: Object.freeze(['--version']), + cwd, + executable, + })); + if (result.exitCode !== 0 || result.termination !== undefined) { + const message = result.termination === 'timed-out' + ? 'Cursor Agent version probe timed out.' + : result.termination === 'output-limit' + ? 'Cursor Agent version probe exceeded its output limit.' + : `Cursor Agent version probe exited with code ${result.exitCode ?? 'unknown'}.`; + return Object.freeze({ + diagnostics: freezeDiagnostics([diagnostic('AB6029', message, 'warning', target)]), + unavailable: false, + }); + } + const version = versionFrom(`${result.stdout}\n${result.stderr}`); + return Object.freeze({ + diagnostics: Object.freeze([]), + unavailable: false, + ...(version === undefined ? {} : { version }), + }); + } catch (error) { + if (isErrno(error, 'ENOENT')) { + return Object.freeze({ + diagnostics: freezeDiagnostics([diagnostic( + 'AB6029', + 'Cursor Agent is not installed or is not on PATH; local pinned-schema validation still ran.', + 'info', + target, + )]), + unavailable: true, + }); + } + return Object.freeze({ + diagnostics: freezeDiagnostics([diagnostic( + 'AB6029', + 'Cursor Agent version probe could not be started; local pinned-schema validation still ran.', + 'warning', + target, + )]), + unavailable: false, + }); + } +}; + +const pathExists = async (path: string): Promise => { + try { + await lstat(path); + return true; + } catch (error) { + if (isErrno(error, 'ENOENT')) return false; + throw error; + } +}; + +const schemaErrorMessage = (path: DocumentPath, error: ErrorObject): string => { + const location = error.instancePath.length === 0 ? '/' : error.instancePath; + return `${path}${location}: ${error.message ?? 'schema validation failed'}.`; +}; + +const readDocuments = async ( + pluginDirectory: string, + target: string, +): Promise> => { + const diagnostics: Diagnostic[] = []; + const documents: ParsedDocument[] = []; + for (const contract of documentContracts) { + const file = join(pluginDirectory, contract.path); + let source: string; + try { + source = await readFile(file, 'utf8'); + } catch (error) { + if (isErrno(error, 'ENOENT')) { + if (contract.required) { + diagnostics.push(diagnostic( + 'AB6027', + `${contract.path} is required in a generated Cursor bundle.`, + 'error', + target, + )); + } + continue; + } + diagnostics.push(diagnostic( + 'AB6027', + `${contract.path} could not be read for pinned-schema validation.`, + 'error', + target, + )); + continue; + } + let value: unknown; + try { + value = JSON.parse(source) as unknown; + } catch { + diagnostics.push(diagnostic( + 'AB6027', + `${contract.path} is not valid JSON.`, + 'error', + target, + )); + continue; + } + documents.push(Object.freeze({ path: contract.path, value })); + if (contract.validate(value)) continue; + diagnostics.push(...(contract.validate.errors ?? []).map((error) => diagnostic( + 'AB6027', + schemaErrorMessage(contract.path, error), + 'error', + target, + ))); + } + return Object.freeze({ + diagnostics: freezeDiagnostics(diagnostics), + documents: Object.freeze(documents), + }); +}; + +const manifestPrecedenceDiagnostics = async ( + pluginDirectory: string, + target: string, +): Promise => { + const present = await Promise.all(manifestCandidates.map(async (candidate) => ({ + candidate, + exists: await pathExists(join(pluginDirectory, candidate)), + }))); + const selected = present.find((entry) => entry.exists)?.candidate; + if (selected === undefined || selected === manifestCandidates[0]) return Object.freeze([]); + return freezeDiagnostics([diagnostic( + 'AB6028', + `The pinned Cursor loader manifest precedence would select ${selected}; generated Cursor bundles require ${manifestCandidates[0]}.`, + 'error', + target, + )]); +}; + +const displayPath = (root: string, path: string): string => relative(root, path).replaceAll('\\', '/'); + +const symlinkDiagnostics = async ( + pluginDirectory: string, + target: string, +): Promise => { + let rootRealPath: string; + try { + rootRealPath = await realpath(pluginDirectory); + } catch (error) { + if (isErrno(error, 'ENOENT')) return Object.freeze([]); + return freezeDiagnostics([diagnostic( + 'AB6028', + 'The Cursor bundle directory could not be resolved for symlink containment validation.', + 'error', + target, + )]); + } + const diagnostics: Diagnostic[] = []; + const visit = async (directory: string): Promise => { + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch { + diagnostics.push(diagnostic( + 'AB6028', + `${displayPath(pluginDirectory, directory)} could not be inspected for symlink containment.`, + 'error', + target, + )); + return; + } + entries.sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + const path = join(directory, entry.name); + if (entry.isSymbolicLink()) { + try { + const targetPath = await realpath(path); + if (!isInsideOrEqual(rootRealPath, targetPath)) { + diagnostics.push(diagnostic( + 'AB6028', + `${displayPath(pluginDirectory, path)} is a symlink whose real target escapes the Cursor bundle directory.`, + 'error', + target, + )); + } + } catch { + diagnostics.push(diagnostic( + 'AB6028', + `${displayPath(pluginDirectory, path)} is a symlink whose real target cannot be resolved inside the Cursor bundle directory.`, + 'error', + target, + )); + } + continue; + } + if (entry.isDirectory()) await visit(path); + } + }; + await visit(pluginDirectory); + return freezeDiagnostics(diagnostics); +}; + +const isAllowedTokenLocation = (path: DocumentPath, segments: readonly (number | string)[]): boolean => { + if (path === 'mcp.json' && segments[0] === 'mcpServers' && typeof segments[1] === 'string') { + if (segments.length === 3) return segments[2] === 'command' || segments[2] === 'cwd' || segments[2] === 'url'; + if (segments.length === 4 && typeof segments[3] === 'number') return segments[2] === 'args'; + if (segments.length === 4 && typeof segments[3] === 'string') { + return segments[2] === 'env' || segments[2] === 'headers'; + } + } + return path === 'hooks/hooks.json' && + segments.length === 4 && + segments[0] === 'hooks' && + typeof segments[1] === 'string' && + typeof segments[2] === 'number' && + segments[3] === 'command'; +}; + +const tokenLocation = (segments: readonly (number | string)[]): string => + `/${segments.map((segment) => String(segment).replaceAll('~', '~0').replaceAll('/', '~1')).join('/')}`; + +const tokenDiagnostics = ( + document: ParsedDocument, + target: string, +): readonly Diagnostic[] => { + const diagnostics: Diagnostic[] = []; + const visit = (value: unknown, segments: readonly (number | string)[]): void => { + if (typeof value === 'string') { + if (value.includes(cursorPluginRootToken) && !isAllowedTokenLocation(document.path, segments)) { + diagnostics.push(diagnostic( + 'AB6028', + `${document.path}${tokenLocation(segments)} uses CURSOR_PLUGIN_ROOT where the pinned Cursor loader does not substitute CURSOR_PLUGIN_ROOT.`, + 'error', + target, + )); + } + return; + } + if (Array.isArray(value)) { + value.forEach((entry, index) => { visit(entry, [...segments, index]); }); + return; + } + if (typeof value !== 'object' || value === null) return; + Object.entries(value).forEach(([key, entry]) => { visit(entry, [...segments, key]); }); + }; + visit(document.value, []); + return freezeDiagnostics(diagnostics); +}; + +export const validateCursorPlugin = async ( + options: ValidateCursorPluginOptions, +): Promise => { + const pluginDirectory = resolve(options.pluginDirectory); + const executable = options.executable ?? 'cursor-agent'; + const run = options.run ?? runCursorCommand; + const [probe, localDocuments, precedence, symlinks] = await Promise.all([ + probeCursor(executable, dirname(pluginDirectory), run, options.target), + readDocuments(pluginDirectory, options.target), + manifestPrecedenceDiagnostics(pluginDirectory, options.target), + symlinkDiagnostics(pluginDirectory, options.target), + ]); + const transparency = diagnostic( + 'AB6026', + `Cursor publishes no plugin-validate devtools verb; this report validates local bytes against schemas pinned at cursor/plugins@${pinnedCursorPluginCommit} (${capabilityTable.provenance.observedAt} evidence).`, + 'info', + options.target, + ); + const diagnostics = freezeDiagnostics([ + transparency, + ...probe.diagnostics, + ...localDocuments.diagnostics, + ...precedence, + ...symlinks, + ...localDocuments.documents.flatMap((document) => tokenDiagnostics(document, options.target)), + ]); + const failed = diagnostics.some((entry) => entry.severity === 'error'); + const warnings = diagnostics.some((entry) => entry.severity === 'warning'); + const status: CursorPluginValidationStatus = failed + ? 'failed' + : warnings + ? 'warnings' + : probe.unavailable + ? 'unavailable' + : 'passed'; + return Object.freeze({ + diagnostics, + host: 'cursor', + status, + target: options.target, + ...(probe.version === undefined ? {} : { version: probe.version }), + }); +}; diff --git a/packages/agent-bundle/src/test/index.ts b/packages/agent-bundle/src/test/index.ts index bc0494937..f8112e7de 100644 --- a/packages/agent-bundle/src/test/index.ts +++ b/packages/agent-bundle/src/test/index.ts @@ -160,3 +160,11 @@ export type { CodexPluginValidationStatus, ValidateCodexPluginOptions, } from '../host-contracts/codex-plugin-validation.ts'; +export { validateCursorPlugin } from '../host-contracts/cursor-plugin-validation.ts'; +export type { + CursorPluginCommandResult, + CursorPluginCommandRunner, + CursorPluginValidationReport, + CursorPluginValidationStatus, + ValidateCursorPluginOptions, +} from '../host-contracts/cursor-plugin-validation.ts'; diff --git a/packages/agent-bundle/tests/adapter-metadata.test.ts b/packages/agent-bundle/tests/adapter-metadata.test.ts index d0757290a..a4d1e39df 100644 --- a/packages/agent-bundle/tests/adapter-metadata.test.ts +++ b/packages/agent-bundle/tests/adapter-metadata.test.ts @@ -205,7 +205,7 @@ it('records observed capability versions and rehashes schema snapshots against p } if (target === 'cursor') { - expect(sha256Hex(capability)).toBe('46f47e4daa8d072b8319440f912a72ea488b22893803d89382cec18998fb13eb'); + expect(sha256Hex(capability)).toBe('d42dc98d3c7f1f91dd6ef733d6727618c30a7d8d41fdf030165e06ce46345223'); const pluginSchema = JSON.parse(await readFile( new URL('../src/adapters/schemas/cursor/plugin.schema.json', import.meta.url), 'utf8', diff --git a/packages/agent-bundle/tests/cursor-plugin-validation.test.ts b/packages/agent-bundle/tests/cursor-plugin-validation.test.ts new file mode 100644 index 000000000..e7271a542 --- /dev/null +++ b/packages/agent-bundle/tests/cursor-plugin-validation.test.ts @@ -0,0 +1,378 @@ +import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { afterEach, expect, it } from '@rstest/core'; + +import { + validateCursorPlugin, + type CursorPluginCommandRunner, +} from '../src/host-contracts/cursor-plugin-validation.ts'; + +const fixtureRoots: string[] = []; + +afterEach(async () => { + await Promise.all(fixtureRoots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +const createFixtureRoot = async (): Promise => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-cursor-validation-')); + fixtureRoots.push(root); + return root; +}; + +const writeJson = async (root: string, path: string, value: unknown): Promise => { + const file = join(root, path); + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, `${JSON.stringify(value, null, 2)}\n`); +}; + +const createBundle = async ( + documents: Readonly> = { + '.cursor-plugin/plugin.json': { name: 'fixture-plugin' }, + }, +): Promise => { + const root = await createFixtureRoot(); + await Promise.all(Object.entries(documents).map(([path, value]) => writeJson(root, path, value))); + return root; +}; + +const versionRunner = (): { readonly calls: unknown[]; readonly run: CursorPluginCommandRunner } => { + const calls: unknown[] = []; + return { + calls, + run: async (request) => { + calls.push(request); + return { + exitCode: 0, + signal: null, + stderr: '', + stdout: '2026.08.31-4057e58\n', + }; + }, + }; +}; + +it('records the Cursor version and always discloses local pinned-schema validation', async () => { + const pluginDirectory = await createBundle(); + const fixture = versionRunner(); + + const report = await validateCursorPlugin({ + pluginDirectory, + run: fixture.run, + target: 'cursor', + }); + + expect(fixture.calls).toEqual([ + expect.objectContaining({ + args: ['--version'], + executable: 'cursor-agent', + }), + ]); + expect(report).toEqual({ + diagnostics: [expect.objectContaining({ + code: 'AB6026', + message: expect.stringContaining('070189284e702e8a4d2e3cc8913994b204c5337a'), + severity: 'info', + target: 'cursor', + })], + host: 'cursor', + status: 'passed', + target: 'cursor', + version: '2026.08.31-4057e58', + }); + expect(Object.isFrozen(report)).toBe(true); + expect(Object.isFrozen(report.diagnostics)).toBe(true); +}); + +it('keeps local validation active when cursor-agent is absent', async () => { + const validPluginDirectory = await createBundle(); + const invalidPluginDirectory = await createBundle({ + '.cursor-plugin/plugin.json': { + name: 'fixture-plugin', + unknownField: true, + }, + }); + const missing = Object.assign(new Error('spawn cursor-agent ENOENT'), { code: 'ENOENT' }); + + const unavailable = await validateCursorPlugin({ + pluginDirectory: validPluginDirectory, + run: async () => { throw missing; }, + target: 'cursor', + }); + expect(unavailable).toMatchObject({ + diagnostics: expect.arrayContaining([ + expect.objectContaining({ code: 'AB6026', severity: 'info' }), + expect.objectContaining({ code: 'AB6029', severity: 'info' }), + ]), + status: 'unavailable', + }); + + const failed = await validateCursorPlugin({ + pluginDirectory: invalidPluginDirectory, + run: async () => { throw missing; }, + target: 'cursor', + }); + expect(failed.status).toBe('failed'); + expect(failed.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'AB6026', severity: 'info' }), + expect.objectContaining({ + code: 'AB6027', + message: expect.stringContaining('additional properties'), + severity: 'error', + }), + expect.objectContaining({ + code: 'AB6029', + message: expect.stringContaining('not installed or is not on PATH'), + severity: 'info', + }), + ])); +}); + +it('accepts valid bytes for every vendored Cursor schema', async () => { + const pluginDirectory = await createBundle({ + '.cursor-plugin/marketplace.json': { + name: 'fixture-marketplace', + plugins: [{ name: 'fixture-plugin', source: './' }], + }, + '.cursor-plugin/plugin.json': { + hooks: './hooks/hooks.json', + mcpServers: './mcp.json', + name: 'fixture-plugin', + }, + 'hooks/hooks.json': { + hooks: { + stop: [{ command: 'node ${CURSOR_PLUGIN_ROOT}/hooks/stop.mjs' }], + }, + version: 1, + }, + 'mcp.json': { + mcpServers: { + fixture: { + args: ['${CURSOR_PLUGIN_ROOT}/mcp/server.mjs'], + command: 'node', + env: { PLUGIN_ROOT: '${CURSOR_PLUGIN_ROOT}' }, + }, + }, + }, + }); + + const report = await validateCursorPlugin({ + pluginDirectory, + run: versionRunner().run, + target: 'cursor', + }); + + expect(report.status).toBe('passed'); + expect(report.diagnostics).toEqual([ + expect.objectContaining({ code: 'AB6026', severity: 'info' }), + ]); +}); + +it('allows CURSOR_PLUGIN_ROOT in remote MCP URLs and headers', async () => { + const pluginDirectory = await createBundle({ + '.cursor-plugin/plugin.json': { + mcpServers: './mcp.json', + name: 'fixture-plugin', + }, + 'mcp.json': { + mcpServers: { + remote: { + headers: { + Authorization: 'Bearer ${CURSOR_PLUGIN_ROOT}', + }, + type: 'streamable-http', + url: '${CURSOR_PLUGIN_ROOT}/mcp', + }, + }, + }, + }); + + const report = await validateCursorPlugin({ + pluginDirectory, + run: versionRunner().run, + target: 'cursor', + }); + + expect(report.diagnostics.filter((entry) => entry.code === 'AB6028')).toEqual([]); +}); + +it('rejects an unknown plugin manifest property under the strict pinned schema', async () => { + const pluginDirectory = await createBundle({ + '.cursor-plugin/plugin.json': { name: 'fixture-plugin', surprise: true }, + }); + + const report = await validateCursorPlugin({ + pluginDirectory, + run: versionRunner().run, + target: 'cursor', + }); + + expect(report).toMatchObject({ + diagnostics: expect.arrayContaining([ + expect.objectContaining({ + code: 'AB6027', + message: expect.stringContaining('.cursor-plugin/plugin.json'), + }), + ]), + status: 'failed', + }); +}); + +it('rejects malformed MCP bytes under the pinned schema', async () => { + const pluginDirectory = await createBundle({ + '.cursor-plugin/plugin.json': { mcpServers: './mcp.json', name: 'fixture-plugin' }, + 'mcp.json': { mcpServers: { fixture: { command: 'node', unknown: true } } }, + }); + + const report = await validateCursorPlugin({ + pluginDirectory, + run: versionRunner().run, + target: 'cursor', + }); + + expect(report.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: 'AB6027', + message: expect.stringContaining('mcp.json'), + }), + ])); + expect(report.status).toBe('failed'); +}); + +it('rejects malformed hook bytes under the pinned schema', async () => { + const pluginDirectory = await createBundle({ + '.cursor-plugin/plugin.json': { hooks: './hooks/hooks.json', name: 'fixture-plugin' }, + 'hooks/hooks.json': { hooks: { inventedEvent: [{ command: 'true' }] } }, + }); + + const report = await validateCursorPlugin({ + pluginDirectory, + run: versionRunner().run, + target: 'cursor', + }); + + expect(report.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: 'AB6027', + message: expect.stringContaining('hooks/hooks.json'), + }), + ])); + expect(report.status).toBe('failed'); +}); + +it('rejects malformed marketplace bytes under the pinned schema', async () => { + const pluginDirectory = await createBundle({ + '.cursor-plugin/marketplace.json': { + name: 'fixture-marketplace', + plugins: [{ name: 'fixture-plugin', source: './', unknown: true }], + }, + '.cursor-plugin/plugin.json': { name: 'fixture-plugin' }, + }); + + const report = await validateCursorPlugin({ + pluginDirectory, + run: versionRunner().run, + target: 'cursor', + }); + + expect(report.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: 'AB6027', + message: expect.stringContaining('.cursor-plugin/marketplace.json'), + }), + ])); + expect(report.status).toBe('failed'); +}); + +it('requires the generated Cursor manifest while leaving optional documents optional', async () => { + const pluginDirectory = await createBundle({}); + + const report = await validateCursorPlugin({ + pluginDirectory, + run: versionRunner().run, + target: 'cursor', + }); + + expect(report).toMatchObject({ + diagnostics: expect.arrayContaining([ + expect.objectContaining({ + code: 'AB6027', + message: expect.stringContaining('.cursor-plugin/plugin.json is required'), + }), + ]), + status: 'failed', + }); +}); + +it('reports which fallback manifest the pinned loader precedence would select', async () => { + const pluginDirectory = await createBundle({ + '.claude-plugin/plugin.json': { name: 'claude-fallback' }, + 'plugin.json': { name: 'portable-fallback' }, + }); + + const report = await validateCursorPlugin({ + pluginDirectory, + run: versionRunner().run, + target: 'cursor', + }); + + expect(report.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: 'AB6028', + message: expect.stringContaining('.claude-plugin/plugin.json'), + }), + ])); + expect(report.status).toBe('failed'); +}); + +it('reports escaping symlinks in stable path order', async () => { + const pluginDirectory = await createBundle(); + const outsideRoot = await createFixtureRoot(); + const firstOutsideFile = join(outsideRoot, 'first.json'); + const secondOutsideFile = join(outsideRoot, 'second.json'); + await Promise.all([ + writeFile(firstOutsideFile, '{}\n'), + writeFile(secondOutsideFile, '{}\n'), + ]); + await symlink(secondOutsideFile, join(pluginDirectory, 'z-outside.json')); + await symlink(firstOutsideFile, join(pluginDirectory, 'a-outside.json')); + + const report = await validateCursorPlugin({ + pluginDirectory, + run: versionRunner().run, + target: 'cursor', + }); + + expect(report.diagnostics + .filter((entry) => entry.code === 'AB6028') + .map((entry) => entry.message)) + .toEqual([ + 'a-outside.json is a symlink whose real target escapes the Cursor bundle directory.', + 'z-outside.json is a symlink whose real target escapes the Cursor bundle directory.', + ]); + expect(report.status).toBe('failed'); +}); + +it('rejects CURSOR_PLUGIN_ROOT outside loader-substituted fields', async () => { + const pluginDirectory = await createBundle({ + '.cursor-plugin/plugin.json': { + description: 'Unsupported here: ${CURSOR_PLUGIN_ROOT}', + name: 'fixture-plugin', + }, + }); + + const report = await validateCursorPlugin({ + pluginDirectory, + run: versionRunner().run, + target: 'cursor', + }); + + expect(report.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: 'AB6028', + message: expect.stringContaining('does not substitute CURSOR_PLUGIN_ROOT'), + }), + ])); + expect(report.status).toBe('failed'); +});