Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/doctor-followups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Doctor's Claude registration proof now matches the pinned `id === '<name>@inline'` list contract, and versionless Cursor manifests are treated as installed or drifted instead of corrupt or conflicted.
57 changes: 32 additions & 25 deletions packages/agent-bundle/src/install/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -488,7 +484,7 @@ const cursorInventory = async (
name: manifest.name,
path,
state: 'installed',
version: manifest.version,
...(manifest.version === undefined ? {} : { version: manifest.version }),
});
}
return {
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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<Record<string, unknown>>;
if (record.name === name) return true;
return Object.values(record).some((entry) => containsPluginName(entry, name));
};

const claudeBundle = async (
identity: PluginIdentity,
probe: DoctorHostProbe,
Expand Down Expand Up @@ -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',
Expand Down
91 changes: 89 additions & 2 deletions packages/agent-bundle/tests/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Loading