diff --git a/.changeset/doctor-static-validation.md b/.changeset/doctor-static-validation.md new file mode 100644 index 000000000..1d01e72bf --- /dev/null +++ b/.changeset/doctor-static-validation.md @@ -0,0 +1,6 @@ +--- +"agent-bundle": minor +--- + +Surface pinned static bytes-at-rest validation findings for supplied bundles +and installed Cursor plugins through the read-only Doctor report. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 5b1bf355e..6e958a1d9 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -28,7 +28,7 @@ gate a build, a validation, or a dev rebuild. | `AB700x` | Host installation: bundle identity, host availability, scope, command failure, and collision checks. | | `AB7010`–`AB7013` | npm prepack inventory, artifact freshness, package bin targets, and release-version agreement. | | `AB7xxx` | Project preparation and development rebuilds. | -| `AB7300`–`AB7318` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, runtime endpoint health and identity, and durable-state inventory. | +| `AB7300`–`AB7320` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, runtime endpoint health and identity, durable-state inventory, and static bytes-at-rest validation. | | `AB8215`–`AB8218` | Workbench read-only host discovery route. | | `AB8219`–`AB8223` | Workbench live MCP probe route (user-initiated, read-only initialize + tools/list): `AB8219` invalid path, `AB8220` invalid request/method, `AB8221` probe target not found, `AB8222` response over the 16 MiB budget, `AB8223` probe unavailable. | | `AB8xxx` | Development server configuration. | @@ -399,6 +399,17 @@ SQLite lock or shared-memory files. | `AB7317` | info | A live event runtime implements the older strict protocol and does not expose runtime identity. Restart it after upgrading Agent Bundle. | | `AB7318` | error | A live event runtime became unavailable, timed out, or returned an invalid status response during the bounded read-only identity probe. Inspect or restart the runtime, then rerun Doctor. | +## Read-only Doctor static validation (`AB7319`–`AB7320`) + +Doctor reuses the pinned, process-free host document and loader validators. +These checks read installed or supplied bundle bytes only; they never invoke a +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, or when any local plugin contains a symlink that escapes `~/.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; the loader-recognized entry remains `installed`. | Reinstall an invalid Cursor plugin or repair an escaping symlink. For other manifest flavors, use that ecosystem's validator when static document proof is required. | + ## Development package build (`AB7103`) `agent-bundle dev` rebuilds the framework-owned package build (`dist/` bin diff --git a/packages/agent-bundle/src/adapters/claude.ts b/packages/agent-bundle/src/adapters/claude.ts index ff63ee41d..7c85131bb 100644 --- a/packages/agent-bundle/src/adapters/claude.ts +++ b/packages/agent-bundle/src/adapters/claude.ts @@ -429,7 +429,7 @@ const metadata = Object.freeze({ const evidence = capabilityEvidence(claudeName, metadata); const distributionPolicy = capabilityTable.plugin.distributionPolicy; -const artifactValidation = deepFreeze({ +export const claudeArtifactValidation = deepFreeze({ documents: [ Object.freeze({ path: 'hooks/hooks.json', required: false, schema: 'hooks' }), Object.freeze({ path: claudeArtifactPaths.lsp, required: false, schema: 'lsp' }), @@ -3099,7 +3099,7 @@ const artifactLayout: TargetArtifactLayout = Object.freeze({ }); export const claudeAdapter: TargetAdapter = Object.freeze({ - artifactValidation, + artifactValidation: claudeArtifactValidation, artifactLayout, capabilities: Object.freeze({ ...eventRouteCapabilitiesFrom(capabilityTable.hooks.eventRoutes, evidence), diff --git a/packages/agent-bundle/src/host-contracts/claude-plugin-validation.ts b/packages/agent-bundle/src/host-contracts/claude-plugin-validation.ts index db60988ce..872089e5f 100644 --- a/packages/agent-bundle/src/host-contracts/claude-plugin-validation.ts +++ b/packages/agent-bundle/src/host-contracts/claude-plugin-validation.ts @@ -1,5 +1,7 @@ -import { dirname, resolve } from 'node:path'; +import { readFile, readdir } from 'node:fs/promises'; +import { dirname, join, posix, resolve } from 'node:path'; +import { claudeArtifactValidation } from '../adapters/claude.ts'; import type { Diagnostic, DiagnosticSeverity } from '../core/diagnostics.ts'; import { freezeDiagnostics } from '../core/diagnostics.ts'; import { isErrno } from '../core/errors.ts'; @@ -41,6 +43,11 @@ export interface ValidateClaudePluginOptions { readonly target: string; } +export interface ValidateClaudePluginFilesOptions { + readonly pluginDirectory: string; + readonly target: string; +} + const runClaudeCommand: ClaudePluginCommandRunner = (request) => runBoundedChildProcess(request, { labels: { outputLimit: 'output-limit', timedOut: 'timed-out' }, maxOutputBytes: maximumOutputBytes, @@ -87,6 +94,113 @@ const issueLines = (output: string): readonly { readonly message: string; readon return Object.freeze(issues); }; +const matchingDocumentPaths = async ( + root: string, + contractPath: string, +): Promise => { + const wildcard = contractPath.indexOf('*'); + if (wildcard === -1) return Object.freeze([contractPath]); + const directory = posix.dirname(contractPath); + const name = contractPath.slice(directory.length + 1); + const nameWildcard = name.indexOf('*'); + const prefix = name.slice(0, nameWildcard); + const suffix = name.slice(nameWildcard + 1); + let entries; + try { + entries = await readdir(join(root, directory), { withFileTypes: true }); + } catch (error) { + if (isErrno(error, 'ENOENT')) return Object.freeze([]); + throw error; + } + return Object.freeze(entries + .filter((entry) => + (entry.isFile() || entry.isSymbolicLink()) && + entry.name.startsWith(prefix) && + entry.name.endsWith(suffix) && + entry.name.length > prefix.length + suffix.length) + .map((entry) => posix.join(directory, entry.name)) + .sort((left, right) => left.localeCompare(right))); +}; + +const localDiagnostic = ( + code: 'AB6006' | 'AB6011' | 'AB6012', + message: string, + target: string, +): Diagnostic => Object.freeze({ + code, + message, + recovery: 'Repair the generated Claude document so it satisfies the vendored pinned schema, then rebuild.', + severity: 'error', + target, +}); + +export const validateClaudePluginFiles = async ( + options: ValidateClaudePluginFilesOptions, +): Promise => { + const pluginDirectory = resolve(options.pluginDirectory); + const validators = new Map( + claudeArtifactValidation.schemas.map((schema) => [schema.name, schema.validate]), + ); + const diagnostics: Diagnostic[] = []; + for (const contract of claudeArtifactValidation.documents) { + let paths: readonly string[]; + try { + paths = await matchingDocumentPaths(pluginDirectory, contract.path); + } catch { + diagnostics.push(localDiagnostic( + 'AB6012', + `Claude bundle document pattern ${JSON.stringify(contract.path)} could not be read.`, + options.target, + )); + continue; + } + if (paths.length === 0 && contract.required) { + diagnostics.push(localDiagnostic( + 'AB6011', + `Required Claude bundle document ${JSON.stringify(contract.path)} is missing.`, + options.target, + )); + continue; + } + for (const relativePath of paths) { + let document: unknown; + try { + document = JSON.parse(await readFile(join(pluginDirectory, relativePath), 'utf8')) as unknown; + } catch (error) { + if (isErrno(error, 'ENOENT') && !contract.required) continue; + diagnostics.push(localDiagnostic( + isErrno(error, 'ENOENT') ? 'AB6011' : 'AB6006', + isErrno(error, 'ENOENT') + ? `Required Claude bundle document ${JSON.stringify(relativePath)} is missing.` + : `Claude bundle document ${JSON.stringify(relativePath)} is unreadable or not valid JSON.`, + options.target, + )); + continue; + } + const validate = validators.get(contract.schema); + if (validate === undefined) continue; + let issues; + try { + issues = validate(document); + } catch { + issues = Object.freeze([Object.freeze({ + instancePath: '/', + message: 'schema validation failed', + })]); + } + for (const issue of issues) { + diagnostics.push(localDiagnostic( + 'AB6012', + `Claude bundle document ${JSON.stringify(relativePath)} is invalid for schema ` + + `${JSON.stringify(contract.schema)} at ${issue.instancePath || '/'}: ${issue.message}.`, + options.target, + )); + } + } + } + return freezeDiagnostics(diagnostics); +}; + export const validateClaudePlugin = async ( options: ValidateClaudePluginOptions, ): Promise => { diff --git a/packages/agent-bundle/src/host-contracts/codex-plugin-validation.ts b/packages/agent-bundle/src/host-contracts/codex-plugin-validation.ts index 910ef6dcb..a22ac4981 100644 --- a/packages/agent-bundle/src/host-contracts/codex-plugin-validation.ts +++ b/packages/agent-bundle/src/host-contracts/codex-plugin-validation.ts @@ -63,6 +63,11 @@ export interface ValidateCodexPluginOptions { readonly target: string; } +export interface ValidateCodexPluginFilesOptions { + readonly pluginDirectory: string; + readonly target: string; +} + interface PinnedDocumentContract { readonly path: string; readonly required: boolean; @@ -182,6 +187,11 @@ const validatePinnedDocuments = async ( return freezeDiagnostics(diagnostics); }; +export const validateCodexPluginFiles = async ( + options: ValidateCodexPluginFilesOptions, +): Promise => + validatePinnedDocuments(resolve(options.pluginDirectory), options.target); + const generatedJsonFiles = async (directory: string): Promise => Object.freeze((await readdir(directory, { recursive: true })) .filter((path) => path.endsWith('.json')) @@ -388,7 +398,7 @@ export const validateCodexPlugin = async ( options.target, 'Use the vendored pinned schema diagnostics until Codex publishes a plugin validation developer tool.', ), - ...await validatePinnedDocuments(pluginDirectory, options.target), + ...await validateCodexPluginFiles({ pluginDirectory, target: options.target }), ...await schemaGenerationDiagnostics({ cwd, executable, 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 3fbfd6753..ebaa55fc1 100644 --- a/packages/agent-bundle/src/host-contracts/cursor-plugin-validation.ts +++ b/packages/agent-bundle/src/host-contracts/cursor-plugin-validation.ts @@ -57,6 +57,12 @@ export interface ValidateCursorPluginOptions { readonly target: string; } +export interface ValidateCursorPluginFilesOptions { + readonly containmentRoot?: string; + readonly pluginDirectory: string; + readonly target: string; +} + interface CursorProbe { readonly diagnostics: readonly Diagnostic[]; readonly unavailable: boolean; @@ -288,20 +294,26 @@ const displayPath = (root: string, path: string): string => relative(root, path) const symlinkDiagnostics = async ( pluginDirectory: string, + containmentRoot: string, target: string, ): Promise => { let rootRealPath: string; try { - rootRealPath = await realpath(pluginDirectory); + rootRealPath = await realpath(containmentRoot); } 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.', + containmentRoot === pluginDirectory + ? 'The Cursor bundle directory could not be resolved for symlink containment validation.' + : `Cursor local plugin root ${JSON.stringify(containmentRoot)} could not be resolved for symlink containment validation.`, 'error', target, )]); } + const containmentLabel = containmentRoot === pluginDirectory + ? 'the Cursor bundle directory' + : `Cursor local plugin root ${JSON.stringify(containmentRoot)}`; const diagnostics: Diagnostic[] = []; const visit = async (directory: string): Promise => { let entries; @@ -325,7 +337,7 @@ const symlinkDiagnostics = async ( if (!isInsideOrEqual(rootRealPath, targetPath)) { diagnostics.push(diagnostic( 'AB6028', - `${displayPath(pluginDirectory, path)} is a symlink whose real target escapes the Cursor bundle directory.`, + `${displayPath(pluginDirectory, path)} is a symlink whose real target escapes ${containmentLabel}.`, 'error', target, )); @@ -333,7 +345,7 @@ const symlinkDiagnostics = async ( } catch { diagnostics.push(diagnostic( 'AB6028', - `${displayPath(pluginDirectory, path)} is a symlink whose real target cannot be resolved inside the Cursor bundle directory.`, + `${displayPath(pluginDirectory, path)} is a symlink whose real target cannot be resolved inside ${containmentLabel}.`, 'error', target, )); @@ -394,17 +406,47 @@ const tokenDiagnostics = ( return freezeDiagnostics(diagnostics); }; +export const validateCursorPluginSymlinks = async ( + options: ValidateCursorPluginFilesOptions, +): Promise => { + const pluginDirectory = resolve(options.pluginDirectory); + return symlinkDiagnostics( + pluginDirectory, + resolve(options.containmentRoot ?? pluginDirectory), + options.target, + ); +}; + +export const validateCursorPluginFiles = async ( + options: ValidateCursorPluginFilesOptions, +): Promise => { + const pluginDirectory = resolve(options.pluginDirectory); + const [localDocuments, precedence, symlinks] = await Promise.all([ + readDocuments(pluginDirectory, options.target), + manifestPrecedenceDiagnostics(pluginDirectory, options.target), + validateCursorPluginSymlinks({ + ...(options.containmentRoot === undefined ? {} : { containmentRoot: options.containmentRoot }), + pluginDirectory, + target: options.target, + }), + ]); + return freezeDiagnostics([ + ...localDocuments.diagnostics, + ...precedence, + ...symlinks, + ...localDocuments.documents.flatMap((document) => tokenDiagnostics(document, options.target)), + ]); +}; + 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([ + const [probe, localDiagnostics] = await Promise.all([ probeCursor(executable, dirname(pluginDirectory), run, options.target), - readDocuments(pluginDirectory, options.target), - manifestPrecedenceDiagnostics(pluginDirectory, options.target), - symlinkDiagnostics(pluginDirectory, options.target), + validateCursorPluginFiles({ pluginDirectory, target: options.target }), ]); const transparency = diagnostic( 'AB6026', @@ -415,10 +457,7 @@ export const validateCursorPlugin = async ( const diagnostics = freezeDiagnostics([ transparency, ...probe.diagnostics, - ...localDocuments.diagnostics, - ...precedence, - ...symlinks, - ...localDocuments.documents.flatMap((document) => tokenDiagnostics(document, options.target)), + ...localDiagnostics, ]); const failed = diagnostics.some((entry) => entry.severity === 'error'); const warnings = diagnostics.some((entry) => entry.severity === 'warning'); diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index 504d717f2..a34346bb1 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -9,6 +9,12 @@ import { type DiagnosticSeverity, } from '../core/diagnostics.ts'; import { isErrno } from '../core/errors.ts'; +import { validateClaudePluginFiles } from '../host-contracts/claude-plugin-validation.ts'; +import { validateCodexPluginFiles } from '../host-contracts/codex-plugin-validation.ts'; +import { + validateCursorPluginFiles, + validateCursorPluginSymlinks, +} from '../host-contracts/cursor-plugin-validation.ts'; import type { BoundedChildProcessRequest, BoundedChildProcessResult, @@ -232,6 +238,47 @@ const readString = ( return value; }; +interface DoctorStaticValidationIssue { + readonly code: string; + readonly message: string; + readonly severity: DiagnosticSeverity; +} + +type DoctorStaticDiagnosticCode = 'AB7319' | 'AB7320'; + +const staticValidationDiagnostics = ( + code: DoctorStaticDiagnosticCode, + host: DoctorHost, + root: string, + issues: readonly DoctorStaticValidationIssue[], +): readonly Diagnostic[] => freezeDiagnostics(issues.map((issue) => diagnostic( + code, + `Static validation of ${host} bytes at ${JSON.stringify(root)} reported ${issue.code}: ${issue.message}`, + code === 'AB7319' + ? `Rebuild the ${host} bundle from valid source bytes, then rerun Doctor.` + : 'Reinstall the Cursor plugin from a freshly validated bundle, then rerun Doctor.', + issue.severity, + host, +))); + +const validateBundleFiles = async ( + root: string, + host: DoctorHost, +): Promise => { + switch (host) { + case 'claude': + return validateClaudePluginFiles({ pluginDirectory: root, target: host }); + case 'codex': + return validateCodexPluginFiles({ pluginDirectory: root, target: host }); + case 'cursor': + return validateCursorPluginFiles({ pluginDirectory: root, target: host }); + default: { + const exhaustive: never = host; + throw new TypeError(`Unknown Doctor host ${String(exhaustive)}.`); + } + } +}; + export const resolveBundleRoot = async (from: string, host: DoctorHost): Promise => { const root = resolve(from); const manifest = manifestPath(host); @@ -586,6 +633,35 @@ const cursorInventory = async ( )); continue; } + const staticIssues = manifest.manifest === cursorManifestCandidates[0] + ? await validateCursorPluginFiles({ + containmentRoot: installRoot, + pluginDirectory: path, + target: 'cursor', + }) + : await validateCursorPluginSymlinks({ + containmentRoot: installRoot, + pluginDirectory: path, + target: 'cursor', + }); + const staticDiagnostics = staticValidationDiagnostics( + 'AB7320', + 'cursor', + path, + staticIssues, + ); + if (manifest.manifest !== cursorManifestCandidates[0]) { + diagnostics.push(diagnostic( + 'AB7320', + `Cursor plugin entry ${JSON.stringify(path)} uses loader manifest flavor ` + + `${JSON.stringify(manifest.manifest)}; no Cursor-side pinned static document contract exists for that flavor.`, + 'Use that manifest flavor\'s ecosystem validator when static document proof is required; ' + + 'Doctor still checked Cursor local-root symlink containment.', + 'info', + 'cursor', + )); + } + diagnostics.push(...staticDiagnostics); const durableState = await inspectDurableState(path, 'cursor'); if (durableState !== undefined) diagnostics.push(...durableState.diagnostics); findings.push({ @@ -594,7 +670,7 @@ const cursorInventory = async ( manifest: manifest.manifest, name: manifest.name, path, - state: 'installed', + state: staticDiagnostics.some((entry) => entry.severity === 'error') ? 'corrupt' : 'installed', ...(manifest.version === undefined ? {} : { version: manifest.version }), }); } @@ -1122,11 +1198,18 @@ const doctorHost = async ( if (options.from !== undefined) { try { const identity = await readIdentity(options.from, host); + const staticDiagnostics = staticValidationDiagnostics( + 'AB7319', + host, + identity.bundleRoot, + await validateBundleFiles(identity.bundleRoot, host), + ); const checked = host === 'cursor' ? await cursorBundle(identity, home) : host === 'claude' ? await claudeBundle(identity, probed.probe, run) : codexBundle(identity); + diagnostics.push(...staticDiagnostics); diagnostics.push(...checked.diagnostics); if (checked.finding === undefined) { throw new TypeError(`The ${host} bundle check returned no finding.`); @@ -1135,6 +1218,9 @@ const doctorHost = async ( if (durableState !== undefined) diagnostics.push(...durableState.diagnostics); bundle = Object.freeze({ ...checked.finding, + ...(staticDiagnostics.some((entry) => entry.severity === 'error') + ? { state: 'corrupt' as const } + : {}), ...(durableState === undefined ? {} : { durableState }), }); } catch (error) { diff --git a/packages/agent-bundle/tests/doctor.test.ts b/packages/agent-bundle/tests/doctor.test.ts index fd8eabb66..13e353a94 100644 --- a/packages/agent-bundle/tests/doctor.test.ts +++ b/packages/agent-bundle/tests/doctor.test.ts @@ -68,16 +68,45 @@ const createBundle = async ( await writeFile(join(bundle, 'payload.txt'), 'payload\n'); if (host === 'claude') { await Promise.all([ - writeJson(join(bundle, '.claude-plugin/plugin.json'), { name: 'doctor-fixture', version }), + writeJson(join(bundle, '.claude-plugin/plugin.json'), { + author: { name: 'Doctor Fixture' }, + description: 'Doctor fixture plugin.', + name: 'doctor-fixture', + version, + }), writeJson(join(bundle, '.claude-plugin/marketplace.json'), { name: 'doctor-fixture-marketplace', + owner: { name: 'Doctor Fixture' }, + plugins: [{ name: 'doctor-fixture', source: './' }], }), ]); } else if (host === 'codex') { await Promise.all([ - writeJson(join(bundle, '.codex-plugin/plugin.json'), { name: 'doctor-fixture', version }), + writeJson(join(bundle, '.codex-plugin/plugin.json'), { + author: { name: 'Doctor Fixture' }, + description: 'Doctor fixture plugin.', + interface: { + capabilities: ['skills'], + category: 'Productivity', + defaultPrompt: ['Use the doctor fixture.'], + developerName: 'Doctor Fixture', + displayName: 'Doctor Fixture', + longDescription: 'Doctor fixture plugin.', + shortDescription: 'Doctor fixture plugin.', + }, + name: 'doctor-fixture', + skills: './skills/', + version, + }), writeJson(join(bundle, '.agents/plugins/marketplace.json'), { + interface: { displayName: 'Doctor Fixture' }, name: 'doctor-fixture-marketplace', + plugins: [{ + category: 'Productivity', + name: 'doctor-fixture', + policy: { authentication: 'ON_INSTALL', installation: 'AVAILABLE' }, + source: { path: './', source: 'local' }, + }], }), ]); } else { @@ -86,6 +115,8 @@ const createBundle = async ( return bundle; }; +const staticDiagnosticCodes = new Set(['AB7319', 'AB7320']); + const hostReport = (report: DoctorReport, host: DoctorHost) => { const found = report.hosts.find((entry) => entry.host === host); if (found === undefined) throw new Error(`Missing ${host} report.`); @@ -197,6 +228,16 @@ it('inventories all pinned Cursor manifest candidates in loader order', async () ], status: 'known', }); + expect(report.diagnostics.filter((entry) => entry.code === 'AB7320')).toEqual([ + expect.objectContaining({ + message: expect.stringContaining('.claude-plugin/plugin.json'), + severity: 'info', + }), + expect.objectContaining({ + message: expect.stringContaining('plugin.json'), + severity: 'info', + }), + ]); } finally { await fixture.cleanup(); } @@ -206,7 +247,10 @@ it('accepts a versionless Cursor inventory manifest as installed', async () => { const fixture = await temporaryDoctor(); const installRoot = join(fixture.home, '.cursor', 'plugins', 'local'); try { - await writeJson(join(installRoot, 'versionless', 'plugin.json'), { name: 'versionless' }); + await writeJson( + join(installRoot, 'versionless', '.cursor-plugin/plugin.json'), + { name: 'versionless' }, + ); const report = await runDoctor({ endpointDirectory: fixture.endpointDirectory, home: fixture.home, @@ -216,7 +260,7 @@ it('accepts a versionless Cursor inventory manifest as installed', async () => { (entry) => entry.entry === 'versionless', ); expect(finding).toMatchObject({ - manifest: 'plugin.json', + manifest: '.cursor-plugin/plugin.json', name: 'versionless', state: 'installed', }); @@ -234,7 +278,10 @@ it('inventories durable SQLite stores and sidecars without opening them', async const store = 'project-tasks-0123456789abcdef.sqlite'; try { await Promise.all([ - writeJson(join(pluginRoot, 'plugin.json'), { name: 'stateful', version: '1.0.0' }), + writeJson( + join(pluginRoot, '.cursor-plugin/plugin.json'), + { name: 'stateful', version: '1.0.0' }, + ), mkdir(stateRoot, { recursive: true }), ]); await Promise.all([ @@ -316,7 +363,10 @@ it('warns when an installed bundle state directory cannot be read', async () => const fixture = await temporaryDoctor(); const pluginRoot = join(fixture.home, '.cursor', 'plugins', 'local', 'blocked-state'); try { - await writeJson(join(pluginRoot, 'plugin.json'), { name: 'blocked-state', version: '1.0.0' }); + await writeJson( + join(pluginRoot, '.cursor-plugin/plugin.json'), + { name: 'blocked-state', version: '1.0.0' }, + ); await writeFile(join(pluginRoot, 'state'), 'not a directory'); const report = await runDoctor({ endpointDirectory: fixture.endpointDirectory, @@ -387,6 +437,259 @@ it('reports corrupt, symlinked, and interrupted Cursor inventory entries', async } }); +it('accepts valid installed and --from Cursor bytes without static findings', async () => { + const fixture = await temporaryDoctor(); + try { + const bundle = await createBundle(fixture.root, 'cursor'); + const destination = join(fixture.home, '.cursor', 'plugins', 'local', 'doctor-fixture'); + await mkdir(dirname(destination), { recursive: true }); + await cp(bundle, destination, { recursive: true }); + + const report = await runDoctor({ + endpointDirectory: fixture.endpointDirectory, + from: bundle, + home: fixture.home, + hosts: ['cursor'], + }); + + expect(hostReport(report, 'cursor').inventory.findings).toEqual([ + expect.objectContaining({ entry: 'doctor-fixture', state: 'installed' }), + ]); + expect(hostReport(report, 'cursor').bundle?.state).toBe('installed'); + expect(report.diagnostics.filter((entry) => staticDiagnosticCodes.has(entry.code))).toEqual([]); + } finally { + await fixture.cleanup(); + } +}); + +it('marks an installed Cursor plugin corrupt when pinned-schema validation fails', async () => { + const fixture = await temporaryDoctor(); + const pluginRoot = join(fixture.home, '.cursor', 'plugins', 'local', 'schema-invalid'); + try { + await writeJson(join(pluginRoot, '.cursor-plugin/plugin.json'), { + name: 'schema-invalid', + surprise: true, + }); + + const report = await runDoctor({ + endpointDirectory: fixture.endpointDirectory, + home: fixture.home, + hosts: ['cursor'], + }); + + expect(hostReport(report, 'cursor').inventory.findings).toEqual([ + expect.objectContaining({ entry: 'schema-invalid', state: 'corrupt' }), + ]); + expect(report.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: 'AB7320', + message: expect.stringContaining('AB6027'), + severity: 'error', + target: 'cursor', + }), + ])); + } finally { + await fixture.cleanup(); + } +}); + +it('marks an installed Cursor plugin corrupt when a symlink escapes the local plugin root', async () => { + const fixture = await temporaryDoctor(); + const pluginRoot = join(fixture.home, '.cursor', 'plugins', 'local', 'escaping-link'); + const outside = join(fixture.root, 'outside.txt'); + try { + await writeJson(join(pluginRoot, '.cursor-plugin/plugin.json'), { name: 'escaping-link' }); + await writeFile(outside, 'outside\n'); + await symlink(outside, join(pluginRoot, 'outside-link')); + + const report = await runDoctor({ + endpointDirectory: fixture.endpointDirectory, + home: fixture.home, + hosts: ['cursor'], + }); + + expect(hostReport(report, 'cursor').inventory.findings).toEqual([ + expect.objectContaining({ entry: 'escaping-link', state: 'corrupt' }), + ]); + expect(report.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: 'AB7320', + message: expect.stringContaining('AB6028'), + severity: 'error', + target: 'cursor', + }), + ])); + } finally { + await fixture.cleanup(); + } +}); + +it('allows an installed Cursor plugin symlink to another entry inside the local plugin root', async () => { + const fixture = await temporaryDoctor(); + const installRoot = join(fixture.home, '.cursor', 'plugins', 'local'); + const pluginRoot = join(installRoot, 'linked-inside'); + const siblingRoot = join(installRoot, 'shared-target'); + try { + await Promise.all([ + writeJson(join(pluginRoot, 'plugin.json'), { name: 'linked-inside' }), + writeJson(join(siblingRoot, '.cursor-plugin/plugin.json'), { name: 'shared-target' }), + ]); + await writeFile(join(siblingRoot, 'shared.txt'), 'shared\n'); + await symlink(join(siblingRoot, 'shared.txt'), join(pluginRoot, 'shared-link')); + + const report = await runDoctor({ + endpointDirectory: fixture.endpointDirectory, + home: fixture.home, + hosts: ['cursor'], + }); + + expect(hostReport(report, 'cursor').inventory.findings).toEqual([ + expect.objectContaining({ entry: 'linked-inside', state: 'installed' }), + expect.objectContaining({ entry: 'shared-target', state: 'installed' }), + ]); + expect(report.diagnostics.filter((entry) => + entry.code === 'AB7320' && entry.severity === 'error')).toEqual([]); + expect(report.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: 'AB7320', + message: expect.stringContaining('plugin.json'), + severity: 'info', + }), + ])); + } finally { + await fixture.cleanup(); + } +}); + +it('reports invalid --from Cursor bytes and keeps deterministic validator order', async () => { + const fixture = await temporaryDoctor(); + try { + const bundle = await createBundle(fixture.root, 'cursor'); + await writeJson(join(bundle, '.cursor-plugin/plugin.json'), { + description: '${CURSOR_PLUGIN_ROOT} is invalid here', + name: 'doctor-fixture', + surprise: true, + version: '1.2.3', + }); + + const report = await runDoctor({ + endpointDirectory: fixture.endpointDirectory, + from: bundle, + home: fixture.home, + hosts: ['cursor'], + }); + const findings = report.diagnostics.filter((entry) => entry.code === 'AB7319'); + + expect(hostReport(report, 'cursor').bundle?.state).toBe('corrupt'); + expect(findings.map((entry) => entry.message)).toEqual([ + expect.stringContaining('AB6027'), + expect.stringContaining('AB6028'), + ]); + } finally { + await fixture.cleanup(); + } +}); + +it('validates --from Codex bytes without running the live schema generator', async () => { + const fixture = await temporaryDoctor(); + const calls: unknown[] = []; + try { + const bundle = await createBundle(fixture.root, 'codex'); + await writeJson(join(bundle, '.codex-plugin/plugin.json'), { + name: 'Invalid Codex Name', + version: '1.2.3', + }); + + const report = await runDoctor({ + commandRunner: async (request) => { + calls.push(request); + return commandResult({ stdout: 'codex 0.147.0\n' }); + }, + endpointDirectory: fixture.endpointDirectory, + from: bundle, + home: fixture.home, + hosts: ['codex'], + }); + + expect(calls).toEqual([ + expect.objectContaining({ args: ['--version'], executable: 'codex' }), + ]); + expect(hostReport(report, 'codex').bundle?.state).toBe('corrupt'); + expect(report.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: 'AB7319', + message: expect.stringContaining('AB6032'), + severity: 'error', + target: 'codex', + }), + ])); + } finally { + await fixture.cleanup(); + } +}); + +it('validates --from Claude documents from pinned bytes without a new CLI proof', async () => { + const fixture = await temporaryDoctor(); + const calls: unknown[] = []; + try { + const bundle = await createBundle(fixture.root, 'claude'); + await writeJson(join(bundle, '.claude-plugin/plugin.json'), { + name: 'doctor-fixture', + version: '1.2.3', + }); + + const report = await runDoctor({ + commandRunner: async (request) => { + calls.push(request); + return request.args[0] === '--version' + ? commandResult({ stdout: 'claude 2.1.250\n' }) + : commandResult({ stdout: JSON.stringify([{ id: 'doctor-fixture@inline' }]) }); + }, + endpointDirectory: fixture.endpointDirectory, + from: bundle, + home: fixture.home, + hosts: ['claude'], + }); + + expect(calls).toEqual([ + expect.objectContaining({ args: ['--version'], executable: 'claude' }), + expect.objectContaining({ + args: ['--plugin-dir', bundle, 'plugin', 'list', '--json'], + executable: 'claude', + }), + ]); + expect(hostReport(report, 'claude').bundle?.state).toBe('corrupt'); + expect(report.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: 'AB7319', + message: expect.stringContaining('.claude-plugin/plugin.json'), + severity: 'error', + target: 'claude', + }), + ])); + } finally { + await fixture.cleanup(); + } +}); + +it('skips static validation when Cursor home and --from are absent', async () => { + const fixture = await temporaryDoctor(); + try { + const report = await runDoctor({ + endpointDirectory: fixture.endpointDirectory, + home: fixture.home, + hosts: ['cursor'], + }); + + expect(hostReport(report, 'cursor').probe.status).toBe('unavailable'); + expect(hostReport(report, 'cursor').inventory.status).toBe('skipped'); + expect(hostReport(report, 'cursor').bundle).toBeUndefined(); + expect(report.diagnostics.filter((entry) => staticDiagnosticCodes.has(entry.code))).toEqual([]); + } finally { + await fixture.cleanup(); + } +}); + it('reports unreadable Cursor local plugin directory as unknown inventory', async () => { const fixture = await temporaryDoctor(); const installRoot = join(fixture.home, '.cursor', 'plugins', 'local');