From 566f90d48794954beafe44cd0d9429e4988f2ae7 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 1 Sep 2026 23:45:21 +0000 Subject: [PATCH] fix(doctor): match the pinned Claude registration id contract and accept versionless Cursor manifests --- .changeset/doctor-followups.md | 5 ++ packages/agent-bundle/src/install/doctor.ts | 57 +++++++------ packages/agent-bundle/tests/doctor.test.ts | 91 ++++++++++++++++++++- 3 files changed, 126 insertions(+), 27 deletions(-) create mode 100644 .changeset/doctor-followups.md diff --git a/.changeset/doctor-followups.md b/.changeset/doctor-followups.md new file mode 100644 index 000000000..6ced872ce --- /dev/null +++ b/.changeset/doctor-followups.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Doctor's Claude registration proof now matches the pinned `id === '@inline'` list contract, and versionless Cursor manifests are treated as installed or drifted instead of corrupt or conflicted. diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index a8f0e6e13..42e8507df 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -365,23 +365,19 @@ const cursorManifestCandidates = Object.freeze([ const readInstalledManifest = async ( root: string, -): Promise<{ readonly manifest: string; readonly name: string; readonly version: string } | undefined> => { +): Promise<{ readonly manifest: string; readonly name: string; readonly version?: string } | undefined> => { for (const manifest of cursorManifestCandidates) { try { const value = JSON.parse(await readFile(join(root, manifest), 'utf8')) as unknown; - if ( - value !== null && - typeof value === 'object' && - !Array.isArray(value) && - typeof (value as { readonly name?: unknown }).name === 'string' && - typeof (value as { readonly version?: unknown }).version === 'string' - ) { - return Object.freeze({ - manifest, - name: (value as { readonly name: string }).name, - version: (value as { readonly version: string }).version, - }); - } + if (value === null || typeof value !== 'object' || Array.isArray(value)) continue; + const record = value as { readonly name?: unknown; readonly version?: unknown }; + if (typeof record.name !== 'string') continue; + if (record.version !== undefined && typeof record.version !== 'string') continue; + return Object.freeze({ + manifest, + name: record.name, + ...(typeof record.version === 'string' ? { version: record.version } : {}), + }); } catch (error) { if (!isErrno(error, 'ENOENT') && !(error instanceof SyntaxError)) throw error; } @@ -488,7 +484,7 @@ const cursorInventory = async ( name: manifest.name, path, state: 'installed', - version: manifest.version, + ...(manifest.version === undefined ? {} : { version: manifest.version }), }); } return { @@ -570,7 +566,7 @@ const cursorBundle = async ( finding: Object.freeze({ ...base, state: 'corrupt' }), }; } - if (installed.version !== identity.version) { + if (installed.version !== undefined && installed.version !== identity.version) { return { diagnostics: freezeDiagnostics([diagnostic( 'AB7309', @@ -613,14 +609,6 @@ const cursorBundle = async ( } }; -const containsPluginName = (value: unknown, name: string): boolean => { - if (Array.isArray(value)) return value.some((entry) => containsPluginName(entry, name)); - if (value === null || typeof value !== 'object') return false; - const record = value as Readonly>; - if (record.name === name) return true; - return Object.values(record).some((entry) => containsPluginName(entry, name)); -}; - const claudeBundle = async ( identity: PluginIdentity, probe: DoctorHostProbe, @@ -682,7 +670,26 @@ const claudeBundle = async ( finding: Object.freeze({ ...base, state: 'failed' }), }; } - if (!containsPluginName(inventory, identity.name)) { + // Pinned registration contract: tests/support/packed-native-smoke.ts + if (!Array.isArray(inventory)) { + return { + diagnostics: freezeDiagnostics([diagnostic( + 'AB7312', + 'Claude registration proof returned output that does not match the documented list shape.', + `Inspect \`claude --plugin-dir ${identity.bundleRoot} plugin list --json\` and repair the host setup.`, + 'error', + 'claude', + )]), + finding: Object.freeze({ ...base, state: 'failed' }), + }; + } + const entries = inventory; + if (!entries.some((entry) => + entry !== null && + typeof entry === 'object' && + !Array.isArray(entry) && + (entry as { id?: unknown }).id === `${identity.name}@inline` + )) { return { diagnostics: freezeDiagnostics([diagnostic( 'AB7311', diff --git a/packages/agent-bundle/tests/doctor.test.ts b/packages/agent-bundle/tests/doctor.test.ts index 287020932..4dd9e2427 100644 --- a/packages/agent-bundle/tests/doctor.test.ts +++ b/packages/agent-bundle/tests/doctor.test.ts @@ -201,6 +201,55 @@ it('inventories all pinned Cursor manifest candidates in loader order', async () } }); +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' }); + const report = await runDoctor({ + endpointDirectory: fixture.endpointDirectory, + home: fixture.home, + hosts: ['cursor'], + }); + const finding = hostReport(report, 'cursor').inventory.findings.find( + (entry) => entry.entry === 'versionless', + ); + expect(finding).toMatchObject({ + manifest: 'plugin.json', + name: 'versionless', + state: 'installed', + }); + expect(finding).not.toHaveProperty('version'); + expect(report.diagnostics.some((entry) => entry.code === 'AB7304')).toBe(false); + } finally { + await fixture.cleanup(); + } +}); + +it('reports a Cursor inventory manifest with a non-string version as corrupt', async () => { + const fixture = await temporaryDoctor(); + const installRoot = join(fixture.home, '.cursor', 'plugins', 'local'); + try { + await writeJson(join(installRoot, 'bad-version', '.cursor-plugin/plugin.json'), { + name: 'x', + version: 7, + }); + const report = await runDoctor({ + endpointDirectory: fixture.endpointDirectory, + home: fixture.home, + hosts: ['cursor'], + }); + expect(hostReport(report, 'cursor').inventory.findings).toEqual(expect.arrayContaining([ + expect.objectContaining({ entry: 'bad-version', state: 'corrupt' }), + ])); + expect(report.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'AB7304', severity: 'error' }), + ])); + } finally { + await fixture.cleanup(); + } +}); + it('reports corrupt, symlinked, and interrupted Cursor inventory entries', async () => { const fixture = await temporaryDoctor(); const installRoot = join(fixture.home, '.cursor', 'plugins', 'local'); @@ -329,6 +378,27 @@ it('classifies Cursor bundle state as installed, missing, drifted, or conflicted } }); +it('treats a versionless Cursor destination as drifted rather than conflicted', 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 }); + await writeJson(join(destination, '.cursor-plugin/plugin.json'), { name: 'doctor-fixture' }); + const report = await runDoctor({ + endpointDirectory: fixture.endpointDirectory, + from: bundle, + home: fixture.home, + hosts: ['cursor'], + }); + expect(hostReport(report, 'cursor').bundle?.state).toBe('drifted'); + expect(report.diagnostics.some((entry) => entry.code === 'AB7309')).toBe(false); + } finally { + await fixture.cleanup(); + } +}); + it('turns a symlink inside a Cursor bundle into a corrupt finding', async () => { const fixture = await temporaryDoctor(); try { @@ -376,20 +446,37 @@ it.each([ { expectedCode: undefined, expectedState: 'registered', + label: 'id@inline', + registration: commandResult({ stdout: JSON.stringify([{ id: 'doctor-fixture@inline' }]) }), + }, + { + expectedCode: 'AB7311', + expectedState: 'unregistered', + label: 'name-only false positive', registration: commandResult({ stdout: JSON.stringify([{ name: 'doctor-fixture' }]) }), }, { expectedCode: 'AB7311', expectedState: 'unregistered', - registration: commandResult({ stdout: JSON.stringify([{ name: 'other' }]) }), + label: 'other id', + registration: commandResult({ stdout: JSON.stringify([{ id: 'other@inline' }]) }), + }, + { + expectedCode: 'AB7312', + expectedState: 'failed', + label: 'wrapped object shape', + registration: commandResult({ + stdout: JSON.stringify({ plugins: [{ id: 'doctor-fixture@inline' }] }), + }), }, { expectedCode: 'AB7312', expectedState: 'failed', + label: 'non-JSON', registration: commandResult({ stdout: 'not json' }), }, ] as const)( - 'reports Claude registration proof as $expectedState', + 'reports Claude registration proof as $expectedState ($label)', async ({ expectedCode, expectedState, registration }) => { const fixture = await temporaryDoctor(); try {