diff --git a/.changeset/cursor-hooks-manifest-validation.md b/.changeset/cursor-hooks-manifest-validation.md new file mode 100644 index 000000000..b72d71984 --- /dev/null +++ b/.changeset/cursor-hooks-manifest-validation.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Validate the Cursor hooks document that `.cursor-plugin/plugin.json` `hooks` names instead of always reading `hooks/hooks.json`, so `agent-bundle doctor` (`AB7319`/`AB7320`) and `validateCursorPlugin` (`AB6027`) no longer reject a unified `plugin` bundle whose Cursor manifest points at `hooks/hooks-cursor.json` beside the Claude-format `hooks/hooks.json`. A declared hooks file that is missing or leaves the plugin root is now an `AB6027` error, an inline `hooks` object is validated in place, and Doctor's `AB7322` registration proof applies the same folder-discovery fallback. (#442) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 2b9881d39..36320acaa 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -51,7 +51,7 @@ even when no error diagnostic was reported. | 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. | +| `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. The hooks document is the one `.cursor-plugin/plugin.json` `hooks` names — a plugin-root-relative file (`hooks/hooks.json` for the `cursor` target, `hooks/hooks-cursor.json` for the unified `plugin` target, reported under that path) or an inline object (`.cursor-plugin/plugin.json#/hooks`) — falling back to `hooks/hooks.json` folder discovery only when the field is absent; a declared file that is missing or resolves outside the plugin root is an error, and any other `hooks/hooks.json` beside a named document is not read. | 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. | @@ -696,7 +696,7 @@ host CLI, repair a bundle, or perform a live protocol exchange. | Code | Severity | Trigger | Recovery | | --- | --- | --- | --- | | `AB7319` | error | A host tree resolved from `doctor --from` violates its pinned document schemas or process-free loader rules. The message retains the originating build-validator code and detail. | Rebuild that host bundle from valid source bytes, then rerun Doctor. | -| `AB7320` | error / info | Error when a `.cursor-plugin/plugin.json` install violates Cursor's pinned document schemas or token-location rules, when a root `plugin.json` install that declares an Agent Plugins `$schema` violates the pinned Agent Plugins 1.0.0 contract (`AB6035`–`AB6037`, retained in the message), or when any local plugin contains a symlink that escapes `~/.cursor/plugins/local`; the inventory entry is reported as `corrupt`. Info naming the contract applied to an Agent Plugins install, or stating that a `.claude-plugin/plugin.json` (or schema-less root `plugin.json`) install has no Cursor-side pinned static document contract; loader-recognized entries remain `installed`. | Reinstall an invalid Cursor plugin, rebuild an invalid portable bundle, or repair an escaping symlink. For other manifest flavors, use that ecosystem's validator when static document proof is required. | +| `AB7320` | error / info | Error when a `.cursor-plugin/plugin.json` install violates Cursor's pinned document schemas or token-location rules (the hooks document checked is the one the manifest `hooks` field names, so a unified `plugin` bundle's Claude-format `hooks/hooks.json` beside its `hooks/hooks-cursor.json` is not a finding), when a root `plugin.json` install that declares an Agent Plugins `$schema` violates the pinned Agent Plugins 1.0.0 contract (`AB6035`–`AB6037`, retained in the message), or when any local plugin contains a symlink that escapes `~/.cursor/plugins/local`; the inventory entry is reported as `corrupt`. Info naming the contract applied to an Agent Plugins install, or stating that a `.claude-plugin/plugin.json` (or schema-less root `plugin.json`) install has no Cursor-side pinned static document contract; loader-recognized entries remain `installed`. | Reinstall an invalid Cursor plugin, rebuild an invalid portable bundle, or repair an escaping symlink. For other manifest flavors, use that ecosystem's validator when static document proof is required. | ## Install replacement and Doctor install comparison (`AB7005`, `AB7307`–`AB7309`, `AB7321`) @@ -807,7 +807,7 @@ that registration statically and never writes `~/.cursor/hooks.json`. | Code | Severity | Meaning | Recovery | | --- | --- | --- | --- | -| `AB7322` | info / error | Info: an installed `.cursor-plugin/plugin.json` plugin registers plugin-scoped hooks (events and command count listed) and the script each command executes — `${CURSOR_PLUGIN_ROOT}/…` or any relative path, including an interpreter's entry operand — exists under the plugin root (`hooks.state = registered`). Error: the declared hooks file is missing (`missing`), is not a regular file or not a `{ version, hooks: { : [{ command }] } }` document, or an executed script is absent (`stale`). Documents and scripts are probed with `stat` before any read, so a FIFO cannot stall Doctor. | Reinstall the plugin from a bundle whose emitted hooks document and scripts are intact. | +| `AB7322` | info / error | Info: an installed `.cursor-plugin/plugin.json` plugin registers plugin-scoped hooks (from the document its manifest `hooks` field names, or from `hooks/hooks.json` folder discovery when the field is absent; events and command count listed) and the script each command executes — `${CURSOR_PLUGIN_ROOT}/…` or any relative path, including an interpreter's entry operand — exists under the plugin root (`hooks.state = registered`). Error: the declared hooks file is missing (`missing`), is not a regular file or not a `{ version, hooks: { : [{ command }] } }` document, or an executed script is absent (`stale`). Documents and scripts are probed with `stat` before any read, so a FIFO cannot stall Doctor. | Reinstall the plugin from a bundle whose emitted hooks document and scripts are intact. | | `AB7323` | warning | `~/.cursor/hooks.json` registers a command whose executed file (after leading `NAME=value` assignments, `env`, and interpreter options) points into an installed plugin directory — compared on path-component boundaries, case-folded on Windows — so Cursor would deliver that hook twice; or the file is not a valid hooks document. | Remove the plugin-pointing entries or repair the file; manifest registration alone is sufficient. | | `AB7324` | info / warning / error | A staged marketplace repository under `~/.cursor/agent-bundle/marketplaces/` (from `install cursor --mode marketplace`) is imported by Cursor (matching plugin under `~/.cursor/plugins/cache`; info, `registered`), still awaiting the Customize "Add Plugins from Local Repository" step (warning, `unregistered`), or incomplete (error, `corrupt`: manifests missing or failing the pinned schemas, no resolvable Git HEAD, HEAD naming a commit object that does not exist, or a working tree that differs from committed HEAD — verified read-only through `git cat-file -e` / `git --no-optional-locks status` when `git` is available). | Complete the Customize import, use `--mode local`, or remove the staged directory and rerun the installer. | diff --git a/packages/agent-bundle/src/host-contracts/cursor-plugin-validation.ts b/packages/agent-bundle/src/host-contracts/cursor-plugin-validation.ts index fd3affeb9..29f51a571 100644 --- a/packages/agent-bundle/src/host-contracts/cursor-plugin-validation.ts +++ b/packages/agent-bundle/src/host-contracts/cursor-plugin-validation.ts @@ -1,7 +1,7 @@ import { lstat, readdir, readFile, realpath, stat } from 'node:fs/promises'; -import { dirname, join, relative, resolve } from 'node:path'; +import { dirname, isAbsolute, join, posix, relative, resolve } from 'node:path'; -import { Ajv, type ErrorObject } from 'ajv/dist/ajv.js'; +import { Ajv, type ErrorObject, type ValidateFunction } from 'ajv/dist/ajv.js'; import addFormats from 'ajv-formats'; import capabilityTable from '../adapters/capabilities/cursor-2026-08-28.json' with { type: 'json' }; @@ -31,7 +31,61 @@ const manifestCandidates = Object.freeze([ 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'; +type DocumentKind = 'hooks' | 'manifest' | 'marketplace' | 'mcp'; + +const manifestPath = '.cursor-plugin/plugin.json'; +const marketplacePath = '.cursor-plugin/marketplace.json'; +const mcpPath = 'mcp.json'; +/** Cursor's folder-discovery default for hooks, used only when the manifest declares no `hooks` field. */ +export const cursorDefaultHooksPath = 'hooks/hooks.json'; +const inlineHooksPath = `${manifestPath}#/hooks`; + +/** + * Where the pinned Cursor loader reads a plugin's hooks from, resolved from the + * `.cursor-plugin/plugin.json` `hooks` field the way the loader does: a string + * is a plugin-root-relative path that replaces folder discovery (so the default + * `hooks/hooks.json` is not also scanned), an object is an inline hooks + * document, and an absent field falls back to `hooks/hooks.json`. The unified + * `plugin` target relies on the replacement: its Cursor manifest points at + * `hooks/hooks-cursor.json` while `hooks/hooks.json` carries the Claude/Codex + * document (#438). + */ +export type CursorHooksSource = + | Readonly<{ readonly kind: 'default'; readonly path: typeof cursorDefaultHooksPath }> + | Readonly<{ + /** The manifest string as written. */ + readonly declared: string; + /** `false` when the declared path is absolute or walks above the plugin root. */ + readonly insidePluginRoot: boolean; + readonly kind: 'file'; + /** Normalized plugin-root-relative POSIX path (`./hooks/x.json` -> `hooks/x.json`). */ + readonly path: string; + }> + | Readonly<{ readonly kind: 'inline'; readonly value: Readonly> }> + /** `hooks` is present but neither a string nor an object; the pinned plugin schema already rejects it. */ + | Readonly<{ readonly kind: 'invalid' }>; + +const isRecord = (value: unknown): value is Readonly> => + typeof value === 'object' && value !== null && !Array.isArray(value); + +export const resolveCursorHooksSource = (manifest: unknown): CursorHooksSource => { + const declared = isRecord(manifest) ? manifest['hooks'] : undefined; + if (declared === undefined) return Object.freeze({ kind: 'default', path: cursorDefaultHooksPath }); + if (typeof declared === 'string') { + const slashed = declared.replaceAll('\\', '/'); + const absolute = isAbsolute(declared) || posix.isAbsolute(slashed) || /^[A-Za-z]:\//u.test(slashed); + const path = posix.normalize(slashed).replace(/^(?:\.\/)+/u, ''); + const escapes = path === '..' || path.startsWith('../'); + return Object.freeze({ + declared, + insidePluginRoot: !absolute && !escapes && path.length > 0 && path !== '.', + kind: 'file', + path, + }); + } + if (isRecord(declared)) return Object.freeze({ kind: 'inline', value: declared }); + return Object.freeze({ kind: 'invalid' }); +}; export type CursorPluginValidationStatus = 'failed' | 'passed' | 'unavailable' | 'warnings'; @@ -70,10 +124,19 @@ interface CursorProbe { } interface ParsedDocument { - readonly path: DocumentPath; + readonly kind: DocumentKind; + /** Plugin-root-relative display path; `.cursor-plugin/plugin.json#/hooks` for an inline hooks object. */ + readonly path: string; readonly value: unknown; } +interface DocumentContract { + readonly kind: DocumentKind; + readonly path: string; + /** Present-or-error: `.cursor-plugin/plugin.json` always, plus a hooks file the manifest explicitly declares. */ + readonly required: boolean; +} + const runCursorCommand: CursorPluginCommandRunner = (request) => runBoundedChildProcess(request, { labels: { outputLimit: 'output-limit', timedOut: 'timed-out' }, maxOutputBytes: maximumOutputBytes, @@ -85,27 +148,35 @@ 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 validators = Object.freeze({ + hooks: schemaValidator.compile(hooksSchema), + manifest: schemaValidator.compile(pluginSchema), + marketplace: schemaValidator.compile(marketplaceSchema), + mcp: schemaValidator.compile(mcpSchema), +}); + +const validatorFor = (kind: DocumentKind): ValidateFunction => { + switch (kind) { + case 'hooks': + return validators.hooks; + case 'manifest': + return validators.manifest; + case 'marketplace': + return validators.marketplace; + case 'mcp': + return validators.mcp; + default: { + const exhaustive: never = kind; + throw new Error(`Unexpected Cursor document kind: ${String(exhaustive)}`); + } + } +}; + +/** The documents every generated Cursor bundle is checked for; the hooks document is added once the manifest names it. */ +const fixedDocumentContracts: readonly DocumentContract[] = Object.freeze([ + Object.freeze({ kind: 'marketplace' as const, path: marketplacePath, required: false }), + Object.freeze({ kind: 'manifest' as const, path: manifestPath, required: true }), + Object.freeze({ kind: 'mcp' as const, path: mcpPath, required: false }), ]); const recoveryFor = (code: CursorDiagnosticCode, severity: DiagnosticSeverity): string => { @@ -206,68 +277,146 @@ const pathExists = async (path: string): Promise => { } }; -const schemaErrorMessage = (path: DocumentPath, error: ErrorObject): string => { +const schemaErrorMessage = (path: string, error: ErrorObject): string => { const location = error.instancePath.length === 0 ? '/' : error.instancePath; return `${path}${location}: ${error.message ?? 'schema validation failed'}.`; }; -const readDocuments = async ( +interface DocumentReadResult { + readonly diagnostics: readonly Diagnostic[]; + readonly document?: ParsedDocument; +} + +const schemaDiagnostics = (document: ParsedDocument, target: string): readonly Diagnostic[] => { + const validate = validatorFor(document.kind); + if (validate(document.value)) return Object.freeze([]); + return freezeDiagnostics((validate.errors ?? []).map((error) => diagnostic( + 'AB6027', + schemaErrorMessage(document.path, error), + 'error', + target, + ))); +}; + +const readDocument = async ( pluginDirectory: string, + contract: DocumentContract, + missingMessage: 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 { - // `stat` first: `readFile` on a FIFO or device would block until a writer appears. - if (!(await stat(file)).isFile()) throw new Error(`${contract.path} is not a regular file`); - source = await readFile(file, 'utf8'); - } catch (error) { - if (isErrno(error, 'ENOENT')) { - if (contract.required) { - diagnostics.push(diagnostic( +): Promise => { + const file = join(pluginDirectory, contract.path); + let source: string; + try { + // `stat` first: `readFile` on a FIFO or device would block until a writer appears. + if (!(await stat(file)).isFile()) throw new Error(`${contract.path} is not a regular file`); + source = await readFile(file, 'utf8'); + } catch (error) { + if (isErrno(error, 'ENOENT')) { + if (!contract.required) return Object.freeze({ diagnostics: Object.freeze([]) }); + return Object.freeze({ + diagnostics: freezeDiagnostics([diagnostic('AB6027', missingMessage, 'error', target)]), + }); + } + return Object.freeze({ + diagnostics: freezeDiagnostics([diagnostic( + 'AB6027', + `${contract.path} could not be read for pinned-schema validation.`, + 'error', + target, + )]), + }); + } + let value: unknown; + try { + value = JSON.parse(source) as unknown; + } catch { + return Object.freeze({ + diagnostics: freezeDiagnostics([diagnostic('AB6027', `${contract.path} is not valid JSON.`, 'error', target)]), + }); + } + const document: ParsedDocument = Object.freeze({ kind: contract.kind, path: contract.path, value }); + return Object.freeze({ diagnostics: schemaDiagnostics(document, target), document }); +}; + +/** + * The hooks document is the one the manifest names (or the folder-discovery + * default when it names none), validated with the pinned Cursor hooks schema. + * A declared file that is missing is an error: the loader would deliver no + * hooks even though the bundle promised them. + */ +const readHooksDocument = async ( + pluginDirectory: string, + manifest: ParsedDocument | undefined, + target: string, +): Promise => { + const source = resolveCursorHooksSource(manifest?.value); + switch (source.kind) { + case 'default': + return readDocument( + pluginDirectory, + Object.freeze({ kind: 'hooks', path: source.path, required: false }), + `${source.path} is missing.`, + target, + ); + case 'file': { + if (!source.insidePluginRoot) { + return Object.freeze({ + diagnostics: freezeDiagnostics([diagnostic( 'AB6027', - `${contract.path} is required in a generated Cursor bundle.`, + `${manifestPath} declares hooks at ${JSON.stringify(source.declared)}, which does not resolve inside the plugin root; ` + + 'generated Cursor bundles keep the hooks document under the plugin root.', 'error', target, - )); - } - continue; + )]), + }); } - diagnostics.push(diagnostic( - 'AB6027', - `${contract.path} could not be read for pinned-schema validation.`, - 'error', + return readDocument( + pluginDirectory, + Object.freeze({ kind: 'hooks', path: source.path, required: true }), + `${manifestPath} declares hooks at ${JSON.stringify(source.declared)} but ${source.path} is missing from the Cursor bundle; ` + + 'Cursor would load no hooks for it.', 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; + case 'inline': { + const document: ParsedDocument = Object.freeze({ kind: 'hooks', path: inlineHooksPath, value: source.value }); + return Object.freeze({ diagnostics: schemaDiagnostics(document, target), document }); } - 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', + case 'invalid': + // The manifest schema already reports the malformed `hooks` field. + return Object.freeze({ diagnostics: Object.freeze([]) }); + default: { + const exhaustive: never = source; + throw new Error(`Unexpected Cursor hooks source: ${String(exhaustive)}`); + } + } +}; + +const readDocuments = async ( + pluginDirectory: string, + target: string, +): Promise> => { + const diagnostics: Diagnostic[] = []; + const documents: ParsedDocument[] = []; + let manifest: ParsedDocument | undefined; + for (const contract of fixedDocumentContracts) { + const result = await readDocument( + pluginDirectory, + contract, + `${contract.path} is required in a generated Cursor bundle.`, target, - ))); + ); + diagnostics.push(...result.diagnostics); + if (result.document === undefined) continue; + documents.push(result.document); + if (contract.kind === 'manifest') manifest = result.document; } + const hooks = await readHooksDocument(pluginDirectory, manifest, target); + diagnostics.push(...hooks.diagnostics); + if (hooks.document !== undefined) documents.push(hooks.document); return Object.freeze({ diagnostics: freezeDiagnostics(diagnostics), documents: Object.freeze(documents), @@ -361,15 +510,15 @@ const symlinkDiagnostics = async ( return freezeDiagnostics(diagnostics); }; -const isAllowedTokenLocation = (path: DocumentPath, segments: readonly (number | string)[]): boolean => { - if (path === 'mcp.json' && segments[0] === 'mcpServers' && typeof segments[1] === 'string') { +const isAllowedTokenLocation = (kind: DocumentKind, segments: readonly (number | string)[]): boolean => { + if (kind === 'mcp' && 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' && + return kind === 'hooks' && segments.length === 4 && segments[0] === 'hooks' && typeof segments[1] === 'string' && @@ -385,9 +534,12 @@ const tokenDiagnostics = ( target: string, ): readonly Diagnostic[] => { const diagnostics: Diagnostic[] = []; + // An inline manifest `hooks` object is walked as its own hooks document, not as manifest metadata. + const inlineHooks = document.kind === 'manifest' && isRecord(document.value) && isRecord(document.value['hooks']); const visit = (value: unknown, segments: readonly (number | string)[]): void => { + if (inlineHooks && segments.length === 1 && segments[0] === 'hooks') return; if (typeof value === 'string') { - if (value.includes(cursorPluginRootToken) && !isAllowedTokenLocation(document.path, segments)) { + if (value.includes(cursorPluginRootToken) && !isAllowedTokenLocation(document.kind, segments)) { diagnostics.push(diagnostic( 'AB6028', `${document.path}${tokenLocation(segments)} uses CURSOR_PLUGIN_ROOT where the pinned Cursor loader does not substitute CURSOR_PLUGIN_ROOT.`, diff --git a/packages/agent-bundle/src/install/cursor-hooks-registration.ts b/packages/agent-bundle/src/install/cursor-hooks-registration.ts index b355c9008..af6a475f1 100644 --- a/packages/agent-bundle/src/install/cursor-hooks-registration.ts +++ b/packages/agent-bundle/src/install/cursor-hooks-registration.ts @@ -10,6 +10,7 @@ import marketplaceSchema from '../adapters/schemas/cursor/marketplace.schema.jso import pluginSchema from '../adapters/schemas/cursor/plugin.schema.json' with { type: 'json' }; import type { Diagnostic } from '../core/diagnostics.ts'; import { isErrno } from '../core/errors.ts'; +import { resolveCursorHooksSource } from '../host-contracts/cursor-plugin-validation.ts'; import { cursorMarketplacePluginPath, cursorMarketplaceRoot, @@ -19,7 +20,9 @@ import { * Read-only Doctor proof for Cursor hook registration (#407). * * Cursor delivers plugin hooks from the plugin manifest (`.cursor-plugin/plugin.json` - * `hooks` -> `hooks/hooks.json`), substituting `${CURSOR_PLUGIN_ROOT}` and running + * `hooks` -> the named document, `hooks/hooks.json` for the `cursor` target and + * `hooks/hooks-cursor.json` for the unified `plugin` target; `hooks/hooks.json` by + * folder discovery when the field is absent), substituting `${CURSOR_PLUGIN_ROOT}` and running * each command from the plugin root (observed 2026-09-03, Cursor 3.18.25, isolated * HOME: preToolUse/postToolUse/stop fired for the emitted pack exactly like the * known-working ~/.cursor/plugins/local/tracedecay). `~/.cursor/hooks.json` is a @@ -353,28 +356,48 @@ export const inspectCursorPluginHooks = async ( if (manifest.error !== undefined || !Predicate.isObject(manifest.value)) { return { diagnostics: Object.freeze([]), registration: none }; } - const declared = manifest.value.hooks; - if (declared === undefined) return { diagnostics: Object.freeze([]), registration: none }; + // The same manifest-driven resolution the static validator applies (#438): the `hooks` field names the + // document Cursor loads; the folder-discovery default `hooks/hooks.json` applies only when it is absent. + const hooksSource = resolveCursorHooksSource(manifest.value); let parsed: ParsedHooksDocument | undefined; let source: string; - if (typeof declared === 'string') { - source = isAbsolute(declared) ? declared : join(pluginDirectory, declared); - const document = await readJson(source); - if (document.error === 'missing') { - return { - diagnostics: Object.freeze([finding( - 'AB7322', - `Cursor plugin ${JSON.stringify(pluginDirectory)} declares hooks at ${JSON.stringify(declared)} but that file is missing; Cursor loads no hooks for it.`, - 'Reinstall the plugin from a bundle whose hooks document exists.', - 'error', - )]), - registration: Object.freeze({ ...none, source, state: 'missing' }), - }; + switch (hooksSource.kind) { + case 'default': { + source = join(pluginDirectory, hooksSource.path); + const document = await readJson(source); + if (document.error === 'missing') return { diagnostics: Object.freeze([]), registration: none }; + parsed = document.error === undefined ? parseHooksDocument(document.value) : undefined; + break; + } + case 'file': { + source = isAbsolute(hooksSource.declared) ? hooksSource.declared : join(pluginDirectory, hooksSource.path); + const document = await readJson(source); + if (document.error === 'missing') { + return { + diagnostics: Object.freeze([finding( + 'AB7322', + `Cursor plugin ${JSON.stringify(pluginDirectory)} declares hooks at ${JSON.stringify(hooksSource.declared)} but that file is missing; Cursor loads no hooks for it.`, + 'Reinstall the plugin from a bundle whose hooks document exists.', + 'error', + )]), + registration: Object.freeze({ ...none, source, state: 'missing' }), + }; + } + parsed = document.error === undefined ? parseHooksDocument(document.value) : undefined; + break; + } + case 'inline': + source = '.cursor-plugin/plugin.json#hooks'; + parsed = parseHooksDocument(hooksSource.value); + break; + case 'invalid': + source = '.cursor-plugin/plugin.json#hooks'; + parsed = undefined; + break; + default: { + const exhaustive: never = hooksSource; + throw new TypeError(`Unexpected Cursor hooks source ${String(exhaustive)}.`); } - parsed = document.error === undefined ? parseHooksDocument(document.value) : undefined; - } else { - source = '.cursor-plugin/plugin.json#hooks'; - parsed = parseHooksDocument(declared); } if (parsed === undefined) { return { @@ -382,7 +405,7 @@ export const inspectCursorPluginHooks = async ( 'AB7322', `Cursor plugin ${JSON.stringify(pluginDirectory)} hooks document ${JSON.stringify(source)} is not a valid ` + '`{ "version": 1, "hooks": { : [{ "command": ... } | { "type": "prompt", "prompt": ... }] } }` document; Cursor loads no hooks for it.', - 'Rebuild and reinstall the plugin; the emitted hooks/hooks.json must follow the pinned Cursor hooks schema.', + 'Rebuild and reinstall the plugin; the hooks document the manifest names must follow the pinned Cursor hooks schema.', 'error', )]), registration: Object.freeze({ ...none, source, state: 'stale' }), diff --git a/packages/agent-bundle/src/test/installed.ts b/packages/agent-bundle/src/test/installed.ts index 6daea9113..8e7659b67 100644 --- a/packages/agent-bundle/src/test/installed.ts +++ b/packages/agent-bundle/src/test/installed.ts @@ -9,6 +9,7 @@ import { parseArtifactHookIndex, type ArtifactHook } from '../build/hook-index.t import { parseArtifactManifest } from '../build/manifest.ts'; import { digest, sha256Hex } from '../core/digest.ts'; import { eventRuntimeEndpoint } from '../events/ipc.ts'; +import { cursorDefaultHooksPath, resolveCursorHooksSource } from '../host-contracts/cursor-plugin-validation.ts'; import { resolveBundleRoot } from '../install/doctor.ts'; import type { InstallHost } from '../install/install.ts'; import { AgentTestError } from './errors.ts'; @@ -126,7 +127,28 @@ const hostManifestPath = (host: InstallHost): string => { const hostMcpPath = (host: InstallHost): string => host === 'cursor' ? 'mcp.json' : '.mcp.json'; -const hostHookPath = (_host: InstallHost): string => 'hooks/hooks.json'; +/** + * The hook document the installed host loads. Claude and Codex read the + * pinned `hooks/hooks.json`; Cursor reads whatever the installed + * `.cursor-plugin/plugin.json` `hooks` field names (the unified `plugin` + * target points it at `hooks/hooks-cursor.json`, #438), falling back to + * `hooks/hooks.json` folder discovery when the field is absent. + */ +const hostHookPath = (host: InstallHost, installedManifest: Readonly>): string => { + switch (host) { + case 'claude': + case 'codex': + return 'hooks/hooks.json'; + case 'cursor': { + const source = resolveCursorHooksSource(installedManifest); + return source.kind === 'file' ? source.path : cursorDefaultHooksPath; + } + default: { + const exhaustive: never = host; + throw new TypeError(`Unknown installed host ${String(exhaustive)}.`); + } + } +}; const record = (value: unknown): Readonly> | undefined => typeof value === 'object' && value !== null && !Array.isArray(value) @@ -371,7 +393,7 @@ export const openInstalledHostMcpServer = async ( } if (installedHooks !== undefined && installedHooks.length > 0) { const hookDocument = await readJsonRecord( - join(installedRoot, hostHookPath(options.host)), + join(installedRoot, hostHookPath(options.host, installedManifest)), 'hook-commands', 'installed hook document', failures, diff --git a/packages/agent-bundle/tests/cursor-plugin-validation.test.ts b/packages/agent-bundle/tests/cursor-plugin-validation.test.ts index e7271a542..034009169 100644 --- a/packages/agent-bundle/tests/cursor-plugin-validation.test.ts +++ b/packages/agent-bundle/tests/cursor-plugin-validation.test.ts @@ -261,6 +261,141 @@ it('rejects malformed hook bytes under the pinned schema', async () => { expect(report.status).toBe('failed'); }); +const claudeFormatHooks = Object.freeze({ + hooks: { + PreToolUse: [{ hooks: [{ command: 'node "${CLAUDE_PLUGIN_ROOT}/hooks/before-tool.mjs"', type: 'command' }], matcher: 'Bash' }], + Stop: [{ hooks: [{ command: 'node "${CLAUDE_PLUGIN_ROOT}/hooks/stop.mjs"', type: 'command' }] }], + }, +}); + +const cursorFormatHooks = Object.freeze({ + hooks: { + preToolUse: [{ command: 'node "${CURSOR_PLUGIN_ROOT}/hooks/before-tool.cursor.mjs"', matcher: '^Shell$' }], + stop: [{ command: 'node "${CURSOR_PLUGIN_ROOT}/hooks/stop.cursor.mjs"' }], + }, + version: 1, +}); + +// #438: the unified `plugin` bundle lays the Claude/Codex hooks document at the Cursor folder-discovery +// default while its Cursor manifest points at `hooks/hooks-cursor.json`; only the named file counts. +it('validates the hooks document the manifest names, not the folder-discovery default', async () => { + const pluginDirectory = await createBundle({ + '.cursor-plugin/plugin.json': { hooks: './hooks/hooks-cursor.json', name: 'fixture-plugin' }, + 'hooks/hooks-cursor.json': cursorFormatHooks, + 'hooks/hooks.json': claudeFormatHooks, + }); + + const report = await validateCursorPlugin({ + pluginDirectory, + run: versionRunner().run, + target: 'cursor', + }); + + expect(report.diagnostics).toEqual([ + expect.objectContaining({ code: 'AB6026', severity: 'info' }), + ]); + expect(report.status).toBe('passed'); +}); + +it('reports a manifest-named hooks document under its own path with the Cursor hooks schema', async () => { + const pluginDirectory = await createBundle({ + '.cursor-plugin/plugin.json': { hooks: './hooks/hooks-cursor.json', name: 'fixture-plugin' }, + 'hooks/hooks-cursor.json': claudeFormatHooks, + 'hooks/hooks.json': cursorFormatHooks, + }); + + const report = await validateCursorPlugin({ + pluginDirectory, + run: versionRunner().run, + target: 'cursor', + }); + + const hookErrors = report.diagnostics.filter((entry) => entry.code === 'AB6027'); + expect(hookErrors.length).toBeGreaterThan(0); + for (const entry of hookErrors) { + expect(entry.message).toContain('hooks/hooks-cursor.json'); + expect(entry.message).not.toContain('hooks/hooks.json'); + } + expect(report.status).toBe('failed'); +}); + +it('reports a manifest-named hooks document that is missing as AB6027', async () => { + const pluginDirectory = await createBundle({ + '.cursor-plugin/plugin.json': { hooks: './hooks/hooks-cursor.json', name: 'fixture-plugin' }, + 'hooks/hooks.json': cursorFormatHooks, + }); + + const report = await validateCursorPlugin({ + pluginDirectory, + run: versionRunner().run, + target: 'cursor', + }); + + expect(report.diagnostics.filter((entry) => entry.code === 'AB6027')).toEqual([ + expect.objectContaining({ + message: '.cursor-plugin/plugin.json declares hooks at "./hooks/hooks-cursor.json" but hooks/hooks-cursor.json is missing from the Cursor bundle; Cursor would load no hooks for it.', + severity: 'error', + }), + ]); + expect(report.status).toBe('failed'); +}); + +it('rejects a manifest hooks path that leaves the plugin root', async () => { + for (const declared of ['../outside/hooks.json', '/etc/hooks.json']) { + const pluginDirectory = await createBundle({ + '.cursor-plugin/plugin.json': { hooks: declared, name: 'fixture-plugin' }, + }); + + const report = await validateCursorPlugin({ + pluginDirectory, + run: versionRunner().run, + target: 'cursor', + }); + + expect(report.diagnostics.filter((entry) => entry.code === 'AB6027')).toEqual([ + expect.objectContaining({ + message: expect.stringContaining(`declares hooks at ${JSON.stringify(declared)}, which does not resolve inside the plugin root`), + severity: 'error', + }), + ]); + expect(report.status).toBe('failed'); + } +}); + +it('validates an inline manifest hooks object with the Cursor hooks schema and token rules', async () => { + const valid = await createBundle({ + '.cursor-plugin/plugin.json': { hooks: cursorFormatHooks, name: 'fixture-plugin' }, + 'hooks/hooks.json': claudeFormatHooks, + }); + const invalid = await createBundle({ + '.cursor-plugin/plugin.json': { hooks: claudeFormatHooks, name: 'fixture-plugin' }, + }); + + const validReport = await validateCursorPlugin({ pluginDirectory: valid, run: versionRunner().run, target: 'cursor' }); + expect(validReport.diagnostics).toEqual([expect.objectContaining({ code: 'AB6026', severity: 'info' })]); + expect(validReport.status).toBe('passed'); + + const invalidReport = await validateCursorPlugin({ pluginDirectory: invalid, run: versionRunner().run, target: 'cursor' }); + const errors = invalidReport.diagnostics.filter((entry) => entry.code === 'AB6027'); + expect(errors.length).toBeGreaterThan(0); + for (const entry of errors) expect(entry.message).toContain('.cursor-plugin/plugin.json#/hooks'); + expect(invalidReport.status).toBe('failed'); +}); + +it('falls back to hooks/hooks.json folder discovery only when the manifest declares no hooks field', async () => { + const discovered = await createBundle({ + '.cursor-plugin/plugin.json': { name: 'fixture-plugin' }, + 'hooks/hooks.json': claudeFormatHooks, + }); + + const report = await validateCursorPlugin({ pluginDirectory: discovered, run: versionRunner().run, target: 'cursor' }); + + expect(report.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'AB6027', message: expect.stringContaining('hooks/hooks.json/hooks') }), + ])); + expect(report.status).toBe('failed'); +}); + it('rejects malformed marketplace bytes under the pinned schema', async () => { const pluginDirectory = await createBundle({ '.cursor-plugin/marketplace.json': { diff --git a/packages/agent-bundle/tests/doctor.test.ts b/packages/agent-bundle/tests/doctor.test.ts index b6173a3e4..cec396a89 100644 --- a/packages/agent-bundle/tests/doctor.test.ts +++ b/packages/agent-bundle/tests/doctor.test.ts @@ -2016,6 +2016,141 @@ it('proves plugin-scoped Cursor hook registration and flags stale, missing, and } }); +/** + * The unified `plugin` target's Cursor view: `.cursor-plugin/plugin.json` points at the Cursor-format + * `hooks/hooks-cursor.json` while the Claude/Codex-format `hooks/hooks.json` sits at Cursor's + * folder-discovery default (#438). + */ +const writeUnifiedBundleCursorView = async (root: string): Promise => { + await writeJson(join(root, '.cursor-plugin/plugin.json'), { + description: 'Unified bundle fixture.', + hooks: './hooks/hooks-cursor.json', + name: 'unified-fixture', + version: '0.3.5', + }); + await writeJson(join(root, '.claude-plugin/plugin.json'), { + description: 'Unified bundle fixture.', + name: 'unified-fixture', + version: '0.3.5', + }); + await writeJson(join(root, 'hooks/hooks.json'), { + hooks: { + PreToolUse: [{ hooks: [{ command: 'node "${CLAUDE_PLUGIN_ROOT}/hooks/before-tool.mjs"', type: 'command' }], matcher: 'Bash' }], + SessionStart: [{ hooks: [{ command: 'node "${CLAUDE_PLUGIN_ROOT}/hooks/session-start.mjs"', type: 'command' }] }], + Stop: [{ hooks: [{ command: 'node "${CLAUDE_PLUGIN_ROOT}/hooks/stop.mjs"', type: 'command' }] }], + }, + }); + await writeJson(join(root, 'hooks/hooks-cursor.json'), { + hooks: { + preToolUse: [{ command: 'node "${CURSOR_PLUGIN_ROOT}/hooks/before-tool.cursor.mjs"', matcher: '^Shell$' }], + sessionStart: [{ command: 'node "${CURSOR_PLUGIN_ROOT}/hooks/session-start.cursor.mjs"' }], + stop: [{ command: 'node "${CURSOR_PLUGIN_ROOT}/hooks/stop.cursor.mjs"' }], + }, + version: 1, + }); + for (const script of ['before-tool', 'session-start', 'stop']) { + await writeFile(join(root, 'hooks', `${script}.mjs`), 'export {};\n'); + await writeFile(join(root, 'hooks', `${script}.cursor.mjs`), 'export {};\n'); + } +}; + +it('validates the hooks document the Cursor manifest names for an installed unified bundle (#438)', async () => { + const fixture = await temporaryDoctor(); + const pluginRoot = join(fixture.home, '.cursor', 'plugins', 'local', 'unified-fixture'); + try { + await writeUnifiedBundleCursorView(pluginRoot); + + const report = await runDoctor({ endpointDirectory: fixture.endpointDirectory, home: fixture.home, hosts: ['cursor'] }); + + expect(report.diagnostics.filter((entry) => entry.code === 'AB7320')).toEqual([]); + expect(report.diagnostics.filter((entry) => entry.message.includes('AB6027'))).toEqual([]); + expect(hostReport(report, 'cursor').inventory.findings).toEqual([expect.objectContaining({ + entry: 'unified-fixture', + hooks: { + commands: 3, + duplicates: [], + events: ['preToolUse', 'sessionStart', 'stop'], + source: join(pluginRoot, 'hooks/hooks-cursor.json'), + state: 'registered', + }, + state: 'installed', + })]); + expect(report.diagnostics.filter((entry) => entry.code === 'AB7322')).toEqual([ + expect.objectContaining({ message: expect.stringContaining('preToolUse, sessionStart, stop'), severity: 'info' }), + ]); + + // The static validator and the registration proof agree on which file counts: breaking the named + // document is reported under its own path, while the Claude-format default stays out of the report. + await writeJson(join(pluginRoot, 'hooks/hooks-cursor.json'), { hooks: { Stop: [{ command: 'true' }] }, version: 1 }); + const broken = await runDoctor({ endpointDirectory: fixture.endpointDirectory, home: fixture.home, hosts: ['cursor'] }); + const staticErrors = broken.diagnostics.filter((entry) => entry.code === 'AB7320'); + expect(staticErrors.length).toBeGreaterThan(0); + for (const entry of staticErrors) { + expect(entry.message).toContain('AB6027: hooks/hooks-cursor.json'); + expect(entry.severity).toBe('error'); + } + expect(hostReport(broken, 'cursor').inventory.findings).toEqual([expect.objectContaining({ + hooks: expect.objectContaining({ state: 'stale' }), + state: 'corrupt', + })]); + expect(broken.diagnostics.filter((entry) => entry.code === 'AB7322')).toEqual([ + expect.objectContaining({ severity: 'error' }), + ]); + } finally { + await fixture.cleanup(); + } +}); + +it('accepts --from unified bundle Cursor bytes whose manifest names hooks/hooks-cursor.json (#438)', async () => { + const fixture = await temporaryDoctor(); + const bundle = join(fixture.root, 'bundle-plugin'); + try { + await writeUnifiedBundleCursorView(bundle); + + const report = await runDoctor({ + endpointDirectory: fixture.endpointDirectory, + from: bundle, + home: fixture.home, + hosts: ['cursor'], + }); + + expect(report.diagnostics.filter((entry) => entry.code === 'AB7319')).toEqual([]); + expect(hostReport(report, 'cursor').bundle).toMatchObject({ name: 'unified-fixture', version: '0.3.5' }); + expect(hostReport(report, 'cursor').bundle?.state).not.toBe('corrupt'); + } finally { + await fixture.cleanup(); + } +}); + +it('applies Cursor folder discovery of hooks/hooks.json only when the manifest declares no hooks field', async () => { + const fixture = await temporaryDoctor(); + const pluginRoot = join(fixture.home, '.cursor', 'plugins', 'local', 'discovered-fixture'); + try { + await writeJson(join(pluginRoot, '.cursor-plugin/plugin.json'), { name: 'discovered-fixture', version: '1.0.0' }); + await writeJson(join(pluginRoot, 'hooks/hooks.json'), { + hooks: { stop: [{ command: 'node "${CURSOR_PLUGIN_ROOT}/hooks/stop.mjs"' }] }, + version: 1, + }); + await writeFile(join(pluginRoot, 'hooks', 'stop.mjs'), 'export {};\n'); + + const report = await runDoctor({ endpointDirectory: fixture.endpointDirectory, home: fixture.home, hosts: ['cursor'] }); + + expect(report.diagnostics.filter((entry) => entry.code === 'AB7320')).toEqual([]); + expect(hostReport(report, 'cursor').inventory.findings[0]?.hooks).toEqual({ + commands: 1, + duplicates: [], + events: ['stop'], + source: join(pluginRoot, 'hooks/hooks.json'), + state: 'registered', + }); + expect(report.diagnostics.filter((entry) => entry.code === 'AB7322')).toEqual([ + expect.objectContaining({ severity: 'info' }), + ]); + } finally { + await fixture.cleanup(); + } +}); + it('reports a plugin without declared hooks as having no hook registration', async () => { const fixture = await temporaryDoctor(); try { diff --git a/packages/agent-bundle/tests/fixtures/host-install/agent-bundle.config.ts b/packages/agent-bundle/tests/fixtures/host-install/agent-bundle.config.ts index 439aa5fdf..fd8f15858 100644 --- a/packages/agent-bundle/tests/fixtures/host-install/agent-bundle.config.ts +++ b/packages/agent-bundle/tests/fixtures/host-install/agent-bundle.config.ts @@ -18,5 +18,6 @@ export default { mcpCommands: true, }, skills: ['src/skills/probe'], - targets: ['claude', 'codex', 'cursor'], + // `plugin` is the unified bundle whose Cursor manifest names `hooks/hooks-cursor.json` (#438). + targets: ['claude', 'codex', 'cursor', 'plugin'], }; diff --git a/packages/agent-bundle/tests/host-install-proof.test.ts b/packages/agent-bundle/tests/host-install-proof.test.ts index fd2449be2..151b060ea 100644 --- a/packages/agent-bundle/tests/host-install-proof.test.ts +++ b/packages/agent-bundle/tests/host-install-proof.test.ts @@ -63,7 +63,7 @@ beforeAll(async () => { " version: '1.0.0',", ' },', ' routes: { mcpCommands: true },', - " targets: ['claude', 'codex', 'cursor'],", + " targets: ['claude', 'codex', 'cursor', 'plugin'],", '};', '', ].join('\n')); @@ -242,6 +242,7 @@ it('accepts an installed artifact whose manifest declares no resource components claude: join(artifactRoot, 'claude'), codex: join(artifactRoot, 'codex'), cursor: join(artifactRoot, 'cursor'), + plugin: join(artifactRoot, 'plugin'), }), cli: builtFixture().cli, root: cloneRoot, @@ -439,6 +440,12 @@ it('installs into an isolated Cursor home, validates schemas, and is idempotent' proofLevel: proofLabel, skill: '.cursor/plugins/local/host-install-proof/skills/probe/SKILL.md', status: 'passed', + unifiedBundle: { + hooksDocument: 'hooks/hooks-cursor.json', + hooksRegistration: 'registered', + install: 'installed', + staticFindings: { AB6027: 0, AB7320: 0 }, + }, }); expectHygienicReport(report); diff --git a/packages/agent-bundle/tests/packed-host-install-proof.test.ts b/packages/agent-bundle/tests/packed-host-install-proof.test.ts index 78218408d..d5849b25d 100644 --- a/packages/agent-bundle/tests/packed-host-install-proof.test.ts +++ b/packages/agent-bundle/tests/packed-host-install-proof.test.ts @@ -106,6 +106,7 @@ beforeAll(async () => { access(join(installedArtifactRoot, 'claude')), access(join(installedArtifactRoot, 'codex')), access(join(installedArtifactRoot, 'cursor')), + access(join(installedArtifactRoot, 'plugin')), ]); await rm(projectRoot, { force: true, recursive: true }); @@ -119,6 +120,7 @@ beforeAll(async () => { claude: join(installedArtifactRoot, 'claude'), codex: join(installedArtifactRoot, 'codex'), cursor: join(installedArtifactRoot, 'cursor'), + plugin: join(installedArtifactRoot, 'plugin'), }), cli: sourceFixture.cli, root: cleanupRoot, @@ -263,6 +265,12 @@ it('installs the packed tarball into an isolated Cursor home, validates schemas, proofLevel: proofLabel, skill: '.cursor/plugins/local/host-install-proof/skills/probe/SKILL.md', status: 'passed', + unifiedBundle: { + hooksDocument: 'hooks/hooks-cursor.json', + hooksRegistration: 'registered', + install: 'installed', + staticFindings: { AB6027: 0, AB7320: 0 }, + }, }); expect(report.install.version, proofLabel).toBe(fixturePackageVersion); expectHygienicReport(report); diff --git a/packages/agent-bundle/tests/support/host-install.ts b/packages/agent-bundle/tests/support/host-install.ts index 660e8ec94..d29b989b7 100644 --- a/packages/agent-bundle/tests/support/host-install.ts +++ b/packages/agent-bundle/tests/support/host-install.ts @@ -147,7 +147,8 @@ export interface BuiltFixtureProject { } export interface BuiltHostInstallFixture extends BuiltFixtureProject { - readonly bundles: Readonly>; + /** Per-host bundles plus the unified `plugin` bundle (one root, three host manifests). */ + readonly bundles: Readonly>; } export interface BuiltHostInstallTokenFixture extends BuiltFixtureProject { @@ -292,6 +293,17 @@ export interface CursorHostInstallReport { readonly proofLevel: string; readonly skill: string; readonly status: 'passed'; + /** + * The unified `plugin` bundle installed as a Cursor local plugin in its own isolated home: Doctor's + * static validation must follow the manifest to `hooks/hooks-cursor.json` and report zero + * AB7320/AB6027 findings even though a Claude-format `hooks/hooks.json` sits beside it (#438). + */ + readonly unifiedBundle: { + readonly hooksDocument: 'hooks/hooks-cursor.json'; + readonly hooksRegistration: 'registered'; + readonly install: 'installed'; + readonly staticFindings: { readonly AB6027: 0; readonly AB7320: 0 }; + }; } export interface PortableHostInstallReport { @@ -585,7 +597,7 @@ export const buildHostInstallFixture = async (options: { }): Promise => { const built = await buildFixtureProject({ ...(options.buildCommand === undefined ? {} : { buildCommand: options.buildCommand }), - bundleNames: ['claude', 'codex', 'cursor'], + bundleNames: ['claude', 'codex', 'cursor', 'plugin'], environment: options.environment, fixture: 'host-install', ...(options.prepareProject === undefined ? {} : { prepareProject: options.prepareProject }), @@ -596,6 +608,7 @@ export const buildHostInstallFixture = async (options: { claude: join(built.artifactRoot, 'claude'), codex: join(built.artifactRoot, 'codex'), cursor: join(built.artifactRoot, 'cursor'), + plugin: join(built.artifactRoot, 'plugin'), }), }); }; @@ -1245,6 +1258,68 @@ const assertCursorMarketplaceStaging = async ( }); }; +/** + * Installs the unified `plugin` bundle as a Cursor local plugin in a fresh isolated home and asks Doctor + * for the static and registration verdicts. The bundle carries both `hooks/hooks.json` (Claude/Codex + * format) and `hooks/hooks-cursor.json` (Cursor format, named by `.cursor-plugin/plugin.json`), so a + * validator that ignored the manifest would report AB7320/AB6027 against a byte-for-byte install (#438). + */ +const assertUnifiedBundleCursorInstall = async ( + fixture: BuiltHostInstallFixture, + options: HostInstallProofOptions, +): Promise => { + const home = await mkdtemp(join(tmpdir(), 'agent-bundle-host-install-cursor-unified-')); + try { + await mkdir(join(home, '.cursor'), { recursive: true }); + const environment = isolatedEnvironment(options.environment, { HOME: home }); + const result = await runNodeCli(fixture, ['install', 'cursor', '--from', fixture.bundles.plugin, '--json'], { + cwd: fixture.bundles.plugin, + environment, + }); + assertProof(result.exitCode === 0, `Unified bundle Cursor install failed: ${commandDetail(result)}`); + const installed = parseJson(result.stdout, 'unified bundle Cursor install'); + assertInstallResult(installed, 'cursor', 'installed'); + const destination = join(home, '.cursor', 'plugins', 'local', plugin); + assertProof(installed.destination === destination, 'Unified bundle Cursor install did not report the isolated destination.'); + + const manifest = record(await readJson(join(destination, '.cursor-plugin', 'plugin.json'), 'unified bundle Cursor manifest')); + assertProof( + manifest?.hooks === './hooks/hooks-cursor.json', + `Unified bundle Cursor manifest did not name hooks/hooks-cursor.json: ${JSON.stringify(manifest?.hooks)}`, + ); + await access(join(destination, 'hooks', 'hooks.json')).catch(() => fail('Unified bundle install lacks the Claude/Codex hooks/hooks.json.')); + const cursorHooks = parseJson( + await readText(join(destination, 'hooks', 'hooks-cursor.json'), 'unified bundle Cursor hooks document'), + 'unified bundle Cursor hooks document', + ); + assertProof(cursorHooksValidator(cursorHooks), `Unified bundle Cursor hooks document failed its pinned schema: ${JSON.stringify(cursorHooksValidator.errors)}`); + + const report = await runDoctor({ home, hosts: ['cursor'] }); + const staticFindings = report.diagnostics.filter((entry) => entry.code === 'AB7320'); + const schemaFindings = report.diagnostics.filter((entry) => entry.message.includes('AB6027')); + assertProof( + staticFindings.length === 0 && schemaFindings.length === 0, + `Doctor reported static findings against a byte-for-byte unified bundle install: ${JSON.stringify([...staticFindings, ...schemaFindings])}`, + ); + const cursor = report.hosts.find((entry) => entry.host === 'cursor'); + const finding = cursor?.inventory.findings.find((entry) => entry.path === destination); + assertProof(finding?.state === 'installed', `Doctor reported the unified bundle install as ${JSON.stringify(finding?.state)} instead of installed.`); + assertProof( + finding.hooks?.state === 'registered' && finding.hooks.source === join(destination, 'hooks', 'hooks-cursor.json'), + `Doctor did not register hooks from hooks/hooks-cursor.json: ${JSON.stringify(finding.hooks)}`, + ); + assertProof(finding.hooks.events.includes('sessionStart'), 'Doctor did not see the unified bundle sessionStart hook registration.'); + return Object.freeze({ + hooksDocument: 'hooks/hooks-cursor.json', + hooksRegistration: 'registered', + install: 'installed', + staticFindings: Object.freeze({ AB6027: 0, AB7320: 0 }), + }); + } finally { + await rm(home, { force: true, recursive: true }); + } +}; + export const runCursorHostInstallProof = async ( fixture: BuiltHostInstallFixture, options: HostInstallProofOptions, @@ -1306,6 +1381,7 @@ export const runCursorHostInstallProof = async ( const hooksRegistration = await assertCursorHooksRegistration(home, destination); const marketplace = await assertCursorMarketplaceStaging(fixture, home, environment); + const unifiedBundle = await assertUnifiedBundleCursorInstall(fixture, options); return Object.freeze({ destination: normalizedRelative(home, destination), @@ -1336,6 +1412,7 @@ export const runCursorHostInstallProof = async ( proofLevel, skill: normalizedRelative(home, skillPath), status: 'passed', + unifiedBundle, }); } finally { await rm(home, { force: true, recursive: true }); diff --git a/website/docs/en/guide/distribution/installation.mdx b/website/docs/en/guide/distribution/installation.mdx index 65927c36b..daed4c468 100644 --- a/website/docs/en/guide/distribution/installation.mdx +++ b/website/docs/en/guide/distribution/installation.mdx @@ -22,7 +22,7 @@ the root contains the selected host's target directory. | --- | --- | --- | | Claude Code | Delegates to `claude plugin marketplace add` and `claude plugin install`. | `user`, `project`, `local` | | Codex | Delegates to `codex plugin marketplace add` and `codex plugin add`. | `user` | -| Cursor | Copies the bundle into `~/.cursor/plugins/local/` (`--mode local`, the default), because Cursor publishes no non-interactive install verb; `--mode marketplace` instead stages a committed local marketplace repository under `~/.cursor/agent-bundle/marketplaces/` and prints the Customize → Plugins → "Add Plugins from Local Repository" step. Either way Cursor loads the plugin's `hooks/hooks.json` from its manifest, so plugin hooks run with `${CURSOR_PLUGIN_ROOT}` substituted and need no `~/.cursor/hooks.json` entry. | `user` | +| Cursor | Copies the bundle into `~/.cursor/plugins/local/` (`--mode local`, the default), because Cursor publishes no non-interactive install verb; `--mode marketplace` instead stages a committed local marketplace repository under `~/.cursor/agent-bundle/marketplaces/` and prints the Customize → Plugins → "Add Plugins from Local Repository" step. Either way Cursor loads the hooks document the plugin manifest names (`hooks/hooks.json` for the `cursor` target, `hooks/hooks-cursor.json` for the unified `plugin` target), so plugin hooks run with `${CURSOR_PLUGIN_ROOT}` substituted and need no `~/.cursor/hooks.json` entry. | `user` | Because Claude and Codex targets always ship local marketplace manifests, their public CLIs can install the emitted directory directly. The installer fails with a typed diagnostic when the @@ -106,7 +106,7 @@ installed bytes. It never repairs anything. With `--from`, it reports the instal | `AB7322` | info / error | Info when an installed Cursor plugin registers plugin-scoped hooks from its manifest and every command's script exists under the plugin root; error when the declared hooks file is missing or malformed, or an executed script is absent. | | `AB7323` | warning | `~/.cursor/hooks.json` registers a command that points into an installed plugin directory, so Cursor would deliver that hook twice; manifest registration alone is sufficient. | | `AB7324` | info / warning / error | A marketplace staged by `install cursor --mode marketplace` is imported (`registered`), still awaiting the Customize import step (`unregistered`), or incomplete (`corrupt`). | -| `AB7320` | error / info | Error when a `.cursor-plugin/plugin.json` install violates Cursor's pinned document schemas or token-location rules, or when a local plugin contains a symlink escaping `~/.cursor/plugins/local`; the inventory entry is reported as `corrupt`. Info when a `.claude-plugin/plugin.json` or root `plugin.json` install has no Cursor-side pinned static document contract, and the loader-recognized entry stays `installed`. | +| `AB7320` | error / info | Error when a `.cursor-plugin/plugin.json` install violates Cursor's pinned document schemas or token-location rules (the hooks document checked is the one the manifest `hooks` field names, so the unified `plugin` bundle's Claude-format `hooks/hooks.json` beside `hooks/hooks-cursor.json` is not a finding), or when a local plugin contains a symlink escaping `~/.cursor/plugins/local`; the inventory entry is reported as `corrupt`. Info when a `.claude-plugin/plugin.json` or root `plugin.json` install has no Cursor-side pinned static document contract, and the loader-recognized entry stays `installed`. | ## Next diff --git a/website/docs/en/guide/distribution/validation.mdx b/website/docs/en/guide/distribution/validation.mdx index b43f48699..e1643901e 100644 --- a/website/docs/en/guide/distribution/validation.mdx +++ b/website/docs/en/guide/distribution/validation.mdx @@ -66,7 +66,7 @@ schema generators as a **drift signal**, never as a substitute plugin contract. | Code | Severity | Meaning | | --- | --- | --- | | `AB6026` | info | Every Cursor report states that Cursor publishes no plugin-validate verb, and names the vendored schema pin used locally. | -| `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. | +| `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. The hooks document is the one `.cursor-plugin/plugin.json` `hooks` names (`hooks/hooks.json` for the `cursor` target, `hooks/hooks-cursor.json` for the unified `plugin` target, or an inline object), falling back to `hooks/hooks.json` only when the field is absent; a named file that is missing or leaves the plugin root is an error. | | `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. | | `AB6029` | info / warning | The Cursor Agent version probe is unavailable (`ENOENT`, info) or could not complete (warning). Local pinned-schema validation still runs. | | `AB6030` | info | The Codex CLI is unavailable, or the installed release publishes no plugin validation command. | diff --git a/website/docs/zh/guide/distribution/installation.mdx b/website/docs/zh/guide/distribution/installation.mdx index 9cc4910c7..1e536e417 100644 --- a/website/docs/zh/guide/distribution/installation.mdx +++ b/website/docs/zh/guide/distribution/installation.mdx @@ -22,7 +22,7 @@ target 目录。 | --- | --- | --- | | Claude Code | 委托给 `claude plugin marketplace add` 与 `claude plugin install`。 | `user`、`project`、`local` | | Codex | 委托给 `codex plugin marketplace add` 与 `codex plugin add`。 | `user` | -| Cursor | 把捆绑包复制到 `~/.cursor/plugins/local/`(默认的 `--mode local`),因为 Cursor 未发布非交互式安装动词;`--mode marketplace` 则在 `~/.cursor/agent-bundle/marketplaces/` 下暂存一个已提交的本地市场仓库,并打印 Customize → Plugins →“Add Plugins from Local Repository”这一步。无论哪种方式,Cursor 都从清单加载插件的 `hooks/hooks.json`,因此插件 hook 会在替换 `${CURSOR_PLUGIN_ROOT}` 后运行,无需 `~/.cursor/hooks.json` 条目。 | `user` | +| Cursor | 把捆绑包复制到 `~/.cursor/plugins/local/`(默认的 `--mode local`),因为 Cursor 未发布非交互式安装动词;`--mode marketplace` 则在 `~/.cursor/agent-bundle/marketplaces/` 下暂存一个已提交的本地市场仓库,并打印 Customize → Plugins →“Add Plugins from Local Repository”这一步。无论哪种方式,Cursor 都加载插件清单所指定的 hooks 文档(`cursor` 目标为 `hooks/hooks.json`,统一 `plugin` 目标为 `hooks/hooks-cursor.json`),因此插件 hook 会在替换 `${CURSOR_PLUGIN_ROOT}` 后运行,无需 `~/.cursor/hooks.json` 条目。 | `user` | 由于 Claude 与 Codex target 始终随行本地市场清单,它们的公开 CLI 可以直接安装输出的目录。当所选宿主 二进制文件不可用时,安装器会以一条带类型的诊断失败,而不是报告一次它并未完成的成功。宿主安装诊断属于 @@ -94,7 +94,7 @@ Doctor 是只读的。它探测宿主、清点已安装的捆绑包、把它们 | `AB7322` | info / error | 已安装的 Cursor 插件从清单注册了插件级 hook 且每条命令的脚本都存在于插件根之下时为 info;声明的 hooks 文件缺失或格式错误、或被执行的脚本不存在时为 error。 | | `AB7323` | warning | `~/.cursor/hooks.json` 注册了指向某个已安装插件目录的命令,Cursor 会把该 hook 投递两次;仅靠清单注册就已足够。 | | `AB7324` | info / warning / error | 由 `install cursor --mode marketplace` 暂存的市场已被导入(`registered`)、仍在等待 Customize 导入步骤(`unregistered`),或不完整(`corrupt`)。 | -| `AB7320` | error / info | 当某个 `.cursor-plugin/plugin.json` 安装违反 Cursor 被固定的文档 schema 或 token 位置规则,或某个本地插件包含逃逸出 `~/.cursor/plugins/local` 的符号链接时为 error,该清点条目被报告为 `corrupt`。当某个 `.claude-plugin/plugin.json` 或根 `plugin.json` 安装没有 Cursor 侧被固定的静态文档契约时为 info,被加载器识别的条目仍为 `installed`。 | +| `AB7320` | error / info | 当某个 `.cursor-plugin/plugin.json` 安装违反 Cursor 被固定的文档 schema 或 token 位置规则(检查的 hooks 文档是清单 `hooks` 字段所指定的那个,因此统一 `plugin` 捆绑包中与 `hooks/hooks-cursor.json` 并存的 Claude 格式 `hooks/hooks.json` 不构成发现),或某个本地插件包含逃逸出 `~/.cursor/plugins/local` 的符号链接时为 error,该清点条目被报告为 `corrupt`。当某个 `.claude-plugin/plugin.json` 或根 `plugin.json` 安装没有 Cursor 侧被固定的静态文档契约时为 info,被加载器识别的条目仍为 `installed`。 | ## 下一步 diff --git a/website/docs/zh/guide/distribution/validation.mdx b/website/docs/zh/guide/distribution/validation.mdx index 5cfc7a357..79d4bef0e 100644 --- a/website/docs/zh/guide/distribution/validation.mdx +++ b/website/docs/zh/guide/distribution/validation.mdx @@ -60,7 +60,7 @@ schema 校验它们输出的 JSON 文档,并把宿主的 schema 生成器视 | 代码 | 严重级别 | 含义 | | --- | --- | --- | | `AB6026` | info | 每份 Cursor 报告都会声明 Cursor 未发布 plugin-validate 动词,并指明本地校验所用的 vendored schema 固定版本。 | -| `AB6027` | error | 某个必需的生成式 Cursor 文档缺失,或者已存在的 plugin、市场、MCP 或 hooks 文档不可读、不是合法 JSON,或被其固定 schema 拒绝。 | +| `AB6027` | error | 某个必需的生成式 Cursor 文档缺失,或者已存在的 plugin、市场、MCP 或 hooks 文档不可读、不是合法 JSON,或被其固定 schema 拒绝。hooks 文档是 `.cursor-plugin/plugin.json` 的 `hooks` 字段所指定的那个(`cursor` 目标为 `hooks/hooks.json`,统一 `plugin` 目标为 `hooks/hooks-cursor.json`,或内联对象),仅在该字段缺失时才回退到 `hooks/hooks.json`;指定的文件缺失或逃逸出插件根目录时为 error。 | | `AB6028` | error | 生成的字节违反了被固定的 Cursor 加载器证据:清单候选优先级选中了回退清单、符号链接解析到捆绑包之外,或者 `CURSOR_PLUGIN_ROOT` 出现在加载器替换字段之外。 | | `AB6029` | info / warning | Cursor Agent 版本探测不可用(`ENOENT`,info),或未能完成(warning)。本地的固定 schema 校验仍会运行。 | | `AB6030` | info | Codex CLI 不可用,或已安装的 Codex 版本未发布插件校验命令。 |