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/101-plugin-data-followups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Harden `agent-bundle uninstall cursor` and the emitted `install.mjs --uninstall` around the Cursor `PLUGIN_DATA` directory: a symlinked `~/.cursor/agent-bundle` or `agent-bundle/plugin-data` ancestor is refused (`AB7007`) before the recorded directory is read or purged; a default rerun over a `--keep-data` remnant whose preserved `state/` or `PLUGIN_DATA` has since been removed or emptied by hand (an empty directory is pruned, never kept as data) now consumes the remnant (receipt, empty plugin root, recorded host and `plugin-data` directories) instead of staying a `not-installed` no-op forever; and `agent-bundle doctor` names the `PLUGIN_DATA` directory in `AB7307` only when it is this home's real, non-empty directory, reporting a remnant whose preserved state is gone as exhausted instead of inventing `state/`. Follow-up to the review threads on #452. (#519)
30 changes: 22 additions & 8 deletions docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -913,7 +913,11 @@ remnant receipt alone does not make a directory "state-only": when `uninstall`
also retained unowned entries beside (or instead of) `state/`, both the
inventory finding and the `--from` bundle finding read the directory and the
`AB7307` message names those retained entries and points at removing them by
hand, since `uninstall` never will.
hand, since `uninstall` never will. Preserved state is only what `uninstall`
would still keep — a `state/` that holds something, and this home's real,
non-empty `PLUGIN_DATA` directory — so a remnant whose data has since been
removed or emptied is reported as exhausted, with the default `uninstall` that
consumes it as the recovery.

## Managed uninstall (`AB7007`–`AB7009`)

Expand Down Expand Up @@ -943,7 +947,11 @@ open issue). Every mutation is opt-in and bounded by the receipt:
it) or purged; an empty, installer-created one is pruned together with its
`agent-bundle/plugin-data` and `agent-bundle` parents once they empty out; a
recorded path outside this home's `plugin-data` is never touched and the
`data.detail` says so.
`data.detail` says so; a symlinked `agent-bundle` or `plugin-data` ancestor
is refused (`AB7007`) before anything is read or removed, since a recursive
purge of the leaf would follow it outside the Cursor home. Doctor's `AB7307`
names the directory as preserved state only when it is that same real,
non-empty directory.
- **Cursor marketplace** — verifies the staged repository's `HEAD` against the
commit the store receipt recorded and its working tree against that commit
(`git --no-optional-locks status --porcelain --untracked-files=all
Expand Down Expand Up @@ -1004,12 +1012,18 @@ store receipts the `<host root>/agent-bundle/receipts` and
directory kept alive by retained state or unowned entries: `removed` in a
`--plan` result equals `removed` in the completed one. A second run after a
successful uninstall is a `not-installed` no-op. When `--keep-data` left
`state/` behind under a Cursor local root, the remnant receipt written there
stays in place (`receipt.status: 'remnant'`) and a rerun without
`--purge-data` is the same `not-installed` no-op; `--purge-data
--confirm-purge` removes the preserved state and prunes the root, and consumes
the remnant (with the host directories it recorded) even when `state/` has
since been removed by hand.
`state/` (or a written `PLUGIN_DATA` directory) behind under a Cursor local
root, the remnant receipt written there stays in place (`receipt.status:
'remnant'`) and a rerun without `--purge-data` is the same `not-installed`
no-op for as long as that preserved data — or an unowned entry the uninstall
retained — is still there; `--purge-data --confirm-purge` removes the
preserved state and prunes the root. Once the preserved data has been removed
or emptied by hand (an empty `state/` or `PLUGIN_DATA` directory holds no
data, so it is pruned like an installer-created directory rather than kept),
the remnant guards nothing, and the next run — with or without `--purge-data`
— consumes it: the receipt, the empty plugin root, and the host and
`plugin-data` directories it recorded. Doctor reports such a remnant as
exhausted (`AB7307`) instead of claiming preserved state that is gone.

| Code | Severity | Trigger | Recovery |
| --- | --- | --- | --- |
Expand Down
70 changes: 57 additions & 13 deletions packages/agent-bundle/src/install/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -523,30 +523,75 @@ const durableStateReport = (
});
};

/**
* The `PLUGIN_DATA` directory a remnant receipt still guards, reported only when it is real: the path
* `uninstall` itself would treat as this home's (`<cursor root>/agent-bundle/plugin-data/<plugin>` for a
* plugin root at `<cursor root>/plugins/local/<plugin>`) and an existing real directory holding something.
* A recorded path elsewhere (a remnant moved between homes) or one since removed by hand is not preserved
* state, and `uninstall` would not touch it either.
*/
const preservedPluginData = async (pluginRoot: string, receipt: InstallReceipt | undefined): Promise<string | undefined> => {
const recorded = receipt?.cursorExpansion?.pluginData;
if (receipt === undefined || recorded === undefined) return undefined;
const cursorRoot = resolve(pluginRoot, '..', '..', '..');
if (recorded !== join(cursorRoot, 'agent-bundle', 'plugin-data', receipt.plugin)) return undefined;
for (const directory of [join(cursorRoot, 'agent-bundle'), join(cursorRoot, 'agent-bundle', 'plugin-data'), recorded]) {
let metadata: Awaited<ReturnType<typeof lstat>>;
try {
metadata = await lstat(directory);
} catch {
return undefined;
}
if (metadata.isSymbolicLink() || !metadata.isDirectory()) return undefined;
}
try {
return (await readdir(recorded)).length === 0 ? undefined : recorded;
} catch {
return undefined;
}
};

/**
* AB7307 for a Cursor directory that holds no plugin but was left by `uninstall --keep-data`.
* A remnant receipt (owning no files) may also guard unowned entries the uninstall retained, so
* the message reports those extras instead of calling the directory state-only; `stateOnly`
* short-circuits the readdir when the caller already proved the directory holds only `state/`.
* the message reports those extras instead of calling the directory state-only.
*/
const remnantDiagnostic = async (subject: string, path: string, stateOnly: boolean, pluginData?: string): Promise<Diagnostic> => {
const entries = stateOnly ? [] : (await readdir(path)).filter((name) => name !== installReceiptFile);
const extras = entries.filter((name) => !isPreservedRuntimeRoot(name)).sort((left, right) => left.localeCompare(right));
const remnantDiagnostic = async (subject: string, path: string, receipt: InstallReceipt | undefined): Promise<Diagnostic> => {
const allEntries = (await readdir(path)).filter((name) => name !== installReceiptFile);
const extras = allEntries.filter((name) => !isPreservedRuntimeRoot(name)).sort((left, right) => left.localeCompare(right));
// Preserved state is what `uninstall` would still keep: a state/ that holds something (an emptied one is pruned on
// the next run, like the remnant itself) and this home's real, non-empty PLUGIN_DATA directory. Nothing is
// assumed: a remnant whose preserved data has since gone is reported as exactly that.
const stateRoots = allEntries.filter(isPreservedRuntimeRoot);
let stateHeld = false;
for (const name of stateRoots) {
try {
if ((await readdir(join(path, name))).length > 0) stateHeld = true;
} catch {
stateHeld = true;
}
}
const pluginData = await preservedPluginData(path, receipt);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid inventing state for an empty PLUGIN_DATA remnant

When a remnant root contains only its receipt and the recorded PLUGIN_DATA is empty, missing, symlinked, or belongs to another home, preservedPluginData() now returns undefined; preserved is therefore empty, but the existing fallback still renders it as state/. Doctor consequently emits AB7307 claiming the root holds preserved state/ even though no such directory exists; handle the empty preserved set without substituting nonexistent state.

AGENTS.md reference: AGENTS.md:L78-L81

Useful? React with 👍 / 👎.

const preserved = [
...(stateOnly || entries.some(isPreservedRuntimeRoot) ? ['state/'] : []),
...(stateHeld ? ['state/'] : []),
...(pluginData === undefined ? [] : [`the PLUGIN_DATA directory ${pluginData}`]),
];
const preservedText = preserved.length === 0 ? 'state/' : preserved.join(' and ');
const preservedText = preserved.join(' and ');
return diagnostic(
'AB7307',
extras.length === 0
? `${subject} holds only preserved runtime state (${preservedText}) from an earlier \`uninstall --keep-data\`; ` +
'no plugin is installed there.'
? preserved.length === 0
? `${subject} holds only the remnant receipt of an earlier \`uninstall --keep-data\` whose preserved runtime state has since ` +
'been removed; no plugin is installed there.'
: `${subject} holds only preserved runtime state (${preservedText}) from an earlier \`uninstall --keep-data\`; ` +
'no plugin is installed there.'
: `${subject} holds no plugin: an earlier \`uninstall\` retained the unowned ` +
`${extras.length === 1 ? 'entry' : 'entries'} ${extras.map((name) => JSON.stringify(name)).join(', ')}` +
`${preserved.length === 0 ? '' : ` beside preserved runtime state (${preservedText})`}.`,
extras.length === 0
? 'Reinstall the plugin to use the preserved state, or run `agent-bundle uninstall cursor --purge-data --confirm-purge` to remove it.'
? preserved.length === 0
? 'Run `agent-bundle uninstall cursor` (or the bundle\'s `install.mjs --uninstall`) to consume the remnant, or reinstall the plugin.'
: 'Reinstall the plugin to use the preserved state, or run `agent-bundle uninstall cursor --purge-data --confirm-purge` to remove it.'
: 'Reinstall the plugin, or move the retained entries out and remove the directory by hand; `uninstall` never removes unowned entries.',
'info',
'cursor',
Expand Down Expand Up @@ -915,7 +960,7 @@ const cursorInventory = async (
if (remnant) {
const durableState = await inspectDurableState(path, 'cursor');
if (durableState !== undefined) diagnostics.push(...durableState.diagnostics);
diagnostics.push(await remnantDiagnostic(`Cursor plugin entry ${JSON.stringify(path)}`, path, stateOnly, remnantReceipt?.cursorExpansion?.pluginData));
diagnostics.push(await remnantDiagnostic(`Cursor plugin entry ${JSON.stringify(path)}`, path, remnantReceipt));
findings.push({
...(durableState === undefined ? {} : { durableState }),
entry,
Expand Down Expand Up @@ -1812,8 +1857,7 @@ const cursorBundle = async (
diagnostics: freezeDiagnostics([await remnantDiagnostic(
`Cursor destination ${destination} (${identity.name}@${identity.version})`,
destination,
stateOnly,
comparison.receipt?.cursorExpansion?.pluginData,
comparison.receipt,
)]),
finding: Object.freeze({
...base,
Expand Down
26 changes: 19 additions & 7 deletions packages/agent-bundle/src/install/surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,8 +377,10 @@ const cursorUninstallerSource = (): readonly string[] => [
' let stateMetadata;',
" try { stateMetadata = await lstat(stateDirectory); } catch (error) { if (error?.code !== 'ENOENT') throw error; }",
" if (stateMetadata !== undefined && (stateMetadata.isSymbolicLink() || !stateMetadata.isDirectory())) throw unsupported('state');",
' const dataPaths = stateMetadata === undefined ? [] : [stateDirectory];',
" const dataKinds = stateMetadata === undefined ? [] : ['state/ (state kernel, notices journal)'];",
' // A state/ holding nothing is not durable state: pruned like an installer-created directory instead of kept as a remnant.',
' const emptyState = stateMetadata !== undefined && (await readdir(stateDirectory)).length === 0 ? stateDirectory : undefined;',
' const dataPaths = stateMetadata === undefined || emptyState !== undefined ? [] : [stateDirectory];',
" const dataKinds = stateMetadata === undefined || emptyState !== undefined ? [] : ['state/ (state kernel, notices journal)'];",
' // The receipt\'s cursorExpansion records the PLUGIN_DATA directory this installer created for the copy (spec 9.1). Only',
' // the directory at this home\'s own plugin-data location is receipt-owned; a written one is durable state (kept or',
' // purged like state/), an empty one is an installer-created directory that is pruned, a recorded path elsewhere is left alone.',
Expand All @@ -389,17 +391,23 @@ const cursorUninstallerSource = (): readonly string[] => [
' if (recordedPluginData !== undefined && !pluginDataRecorded) {',
' foreignNote = ` The receipt records PLUGIN_DATA at ${recordedPluginData}, outside this home\'s agent-bundle/plugin-data; it is not touched.`;',
' } else if (pluginDataRecorded) {',
' // Reached only through real directories: a symlinked agent-bundle or plugin-data ancestor would let a recursive purge',
' // of the leaf follow it outside the Cursor home, so any link on the way is refused before anything is read or removed.',
' let pluginDataMetadata;',
" try { pluginDataMetadata = await lstat(pluginData); } catch (error) { if (error?.code !== 'ENOENT') throw error; }",
" for (const directory of [join(cursorRoot, 'agent-bundle'), join(cursorRoot, 'agent-bundle', 'plugin-data'), pluginData]) {",
' pluginDataMetadata = undefined;',
" try { pluginDataMetadata = await lstat(directory); } catch (error) { if (error?.code !== 'ENOENT') throw error; }",
' if (pluginDataMetadata === undefined) break;',
' if (pluginDataMetadata.isSymbolicLink() || !pluginDataMetadata.isDirectory()) throw unsupported(relative(cursorRoot, directory));',
' }',
' if (pluginDataMetadata !== undefined) {',
" if (pluginDataMetadata.isSymbolicLink() || !pluginDataMetadata.isDirectory()) throw unsupported(relative(cursorRoot, pluginData));",
' if ((await readdir(pluginData)).length === 0) emptyPluginData = pluginData;',
' else { dataPaths.push(pluginData); dataKinds.push(`the PLUGIN_DATA directory ${pluginData}`); }',
' }',
' }',
" const dataOutcome = dataPaths.length === 0 ? 'absent' : purgeData ? 'purged' : 'kept';",
' const dataDetail = dataPaths.length === 0',
" ? `No durable runtime state exists (no state/ under the installed plugin root${emptyPluginData === undefined ? '' : `; the installer-created PLUGIN_DATA directory ${emptyPluginData} is empty and is pruned`}).${foreignNote}`",
" ? `No durable runtime state exists (${emptyState === undefined ? 'no state/ under the installed plugin root' : 'state/ under the installed plugin root is empty and is pruned'}${emptyPluginData === undefined ? '' : `; the installer-created PLUGIN_DATA directory ${emptyPluginData} is empty and is pruned`}).${foreignNote}`",
' : purgeData',
" ? `Durable runtime state — ${dataKinds.join(' and ')} — is removed (--purge-data --confirm-purge).${foreignNote}`",
" : `Durable runtime state — ${dataKinds.join(' and ')} — is kept; pass --purge-data --confirm-purge to remove it.${foreignNote}`;",
Expand All @@ -411,14 +419,18 @@ const cursorUninstallerSource = (): readonly string[] => [
' ...(keepRoot ? [] : [destination]),',
' ...hostDirectories.map((directory) => join(cursorRoot, directory)),',
' ...(emptyPluginData === undefined ? [] : [emptyPluginData]),',
' ...(emptyState === undefined ? [] : [emptyState]),',
" ...(pluginDataRecorded ? [join(cursorRoot, 'agent-bundle', 'plugin-data'), join(cursorRoot, 'agent-bundle')] : []),",
' ].sort((left, right) => right.length - left.length || left.localeCompare(right));',
' const ownedSet = new Set(owned);',
' const ownedDirectorySet = new Set(ownedDirectories);',
' const remnantOnly = receipt !== undefined && receipt.files.length === 0 && receipt.registrations.length === 0;',
' const purging = purgeData && dataPaths.length > 0;',
' // A keep-data rerun over a remnant is the documented no-op; an explicit purge consumes the remnant even without state/.',
' if (remnantOnly && !purgeData && files.length === 1 && files[0] === join(destination, receiptFile)) {',
' // A keep-data rerun over a remnant whose preserved data (or retained unowned entries) are still there is the documented',
' // no-op. Once state/ and the PLUGIN_DATA directory are gone or emptied by hand the remnant guards nothing, and the rerun',
' // consumes it (receipt, empty plugin root, the host and plugin-data directories it recorded) like an explicit purge would.',
' const remnantGuards = dataPaths.length > 0 || (await listRetained(destination, ownedSet, ownedDirectorySet)).length > 0;',
' if (remnantOnly && !purgeData && remnantGuards && files.length === 1 && files[0] === join(destination, receiptFile)) {',
' // A rerun over what an earlier --keep-data uninstall left behind, still keeping the data: nothing to remove, so the',
' // remnant receipt stays and the run is the documented no-op.',
' console.log(`Not installed ${pluginName}@${pluginVersion} for cursor (local mode) at ${destination}`);',
Expand Down
Loading
Loading