From c46795747f7de1b1b52d6f815c03fca13c387fa4 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 03:40:35 +0000 Subject: [PATCH 1/4] fix(uninstall): validate the agent-bundle and plugin-data ancestors before touching PLUGIN_DATA, consume a remnant whose preserved data is gone on a default rerun, and have Doctor name PLUGIN_DATA only when it is this home's real non-empty directory --- docs/diagnostics.md | 21 +++++--- packages/agent-bundle/src/install/doctor.ts | 35 ++++++++++++-- packages/agent-bundle/src/install/surface.ts | 17 +++++-- .../agent-bundle/src/install/uninstall.ts | 25 +++++++--- packages/agent-bundle/tests/doctor.test.ts | 31 +++++++++++- .../tests/install-surface.test.ts | 48 +++++++++++++++++-- packages/agent-bundle/tests/uninstall.test.ts | 48 ++++++++++++++++--- 7 files changed, 193 insertions(+), 32 deletions(-) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 9b5a1c77b..dc8289e80 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -943,7 +943,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 @@ -1004,12 +1008,15 @@ store receipts the `/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, 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. | Code | Severity | Trigger | Recovery | | --- | --- | --- | --- | diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index ab894eadb..3a0822c63 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -523,15 +523,44 @@ 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 (`/agent-bundle/plugin-data/` for a + * plugin root at `/plugins/local/`) 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 => { + 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>; + 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/`. */ -const remnantDiagnostic = async (subject: string, path: string, stateOnly: boolean, pluginData?: string): Promise => { +const remnantDiagnostic = async (subject: string, path: string, stateOnly: boolean, receipt: InstallReceipt | undefined): Promise => { const entries = stateOnly ? [] : (await readdir(path)).filter((name) => name !== installReceiptFile); const extras = entries.filter((name) => !isPreservedRuntimeRoot(name)).sort((left, right) => left.localeCompare(right)); + const pluginData = await preservedPluginData(path, receipt); const preserved = [ ...(stateOnly || entries.some(isPreservedRuntimeRoot) ? ['state/'] : []), ...(pluginData === undefined ? [] : [`the PLUGIN_DATA directory ${pluginData}`]), @@ -915,7 +944,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, stateOnly, remnantReceipt)); findings.push({ ...(durableState === undefined ? {} : { durableState }), entry, @@ -1813,7 +1842,7 @@ const cursorBundle = async ( `Cursor destination ${destination} (${identity.name}@${identity.version})`, destination, stateOnly, - comparison.receipt?.cursorExpansion?.pluginData, + comparison.receipt, )]), finding: Object.freeze({ ...base, diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index 613f11b86..66ce42aa8 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -389,10 +389,16 @@ 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}`); }', ' }', @@ -417,8 +423,11 @@ const cursorUninstallerSource = (): readonly string[] => [ ' 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}`);', diff --git a/packages/agent-bundle/src/install/uninstall.ts b/packages/agent-bundle/src/install/uninstall.ts index a6025601f..5f700dcdc 100644 --- a/packages/agent-bundle/src/install/uninstall.ts +++ b/packages/agent-bundle/src/install/uninstall.ts @@ -421,6 +421,18 @@ const cursorLocalOwnership = async ( export const cursorPluginDataDirectory = (cursorRoot: string, plugin: string): string => join(cursorRoot, 'agent-bundle', 'plugin-data', plugin); +/** + * Whether this home's `PLUGIN_DATA` directory exists as a real directory 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 (AB7007) before anything is read or removed. + */ +const realPluginDataDirectory = async (cursorRoot: string, pluginData: string): Promise => { + for (const directory of [join(cursorRoot, 'agent-bundle'), join(cursorRoot, 'agent-bundle', 'plugin-data'), pluginData]) { + if (await realDirectory(directory, 'cursor') === undefined) return false; + } + return true; +}; + interface CursorLocalData { /** Installer-created `PLUGIN_DATA` directory that nothing wrote to: pruned like a created host directory, never "data". */ readonly emptyPluginData?: string; @@ -454,7 +466,7 @@ const cursorLocalData = async ( if (recorded !== undefined) { if (recorded !== expected) { foreignPluginData = recorded; - } else if (await realDirectory(recorded, 'cursor') !== undefined) { + } else if (await realPluginDataDirectory(cursorRoot, recorded)) { if ((await readdir(recorded)).length === 0) { emptyPluginData = recorded; } else { @@ -567,11 +579,12 @@ const uninstallCursorLocal = async ( const retained = await listRetained(destination, owned, ownedDirectories); const remnantOnly = ownership.receipt !== undefined && isRemnantReceipt(ownership.receipt); const purging = data.present && policy === 'purge'; - if (remnantOnly && policy !== 'purge' && files.length === 1 && files[0] === receiptPath) { - // A rerun over what an earlier `--keep-data` uninstall left behind, still keeping the data: there is - // nothing to remove, so the remnant receipt stays in place and the run is the documented no-op. An explicit - // `--purge-data --confirm-purge` never takes this path: it consumes the remnant (and its recorded host - // directories) even when state/ has since been removed by hand. + if (remnantOnly && policy !== 'purge' && (data.present || retained.length > 0) && files.length === 1 && files[0] === receiptPath) { + // A rerun over what an earlier `--keep-data` uninstall left behind, still keeping data that is still there + // (or unowned entries that keep the root alive): nothing to remove, so the remnant receipt stays in place and + // the run is the documented no-op. Once the preserved state is gone — state/ or the PLUGIN_DATA directory + // removed or emptied by hand — the remnant guards nothing, and the rerun below consumes it (receipt, empty + // plugin root, the host and plugin-data directories it recorded) like an explicit purge would. return Object.freeze({ ...base, data: data.report, diff --git a/packages/agent-bundle/tests/doctor.test.ts b/packages/agent-bundle/tests/doctor.test.ts index 684463b8f..6a7fa17ac 100644 --- a/packages/agent-bundle/tests/doctor.test.ts +++ b/packages/agent-bundle/tests/doctor.test.ts @@ -11,7 +11,7 @@ import type { TargetArtifactWrite } from '../src/adapters/types.ts'; import { runCli } from '../src/cli.ts'; import { eventRuntimeEndpoint } from '../src/events/ipc.ts'; import { installBundle } from '../src/install/install.ts'; -import { emptyContentHash, installReceiptFormat, installReceiptScopeKey, treeInventory } from '../src/install/receipt.ts'; +import { emptyContentHash, installReceiptFile, installReceiptFormat, installReceiptScopeKey, treeInventory } from '../src/install/receipt.ts'; import { uninstallBundle } from '../src/install/uninstall.ts'; import { doctorEndpointDirectory, @@ -2193,6 +2193,35 @@ it('explains a Cursor directory holding only preserved runtime state instead of expect(entry.message).toContain('retained the unowned entry "operator-notes.md"'); expect(entry.message).not.toContain('beside preserved runtime state'); } + + // A remnant receipt recording a PLUGIN_DATA expansion names that directory as preserved state only while it is + // real: this home's `agent-bundle/plugin-data/`, reached through real directories, existing and holding + // something. Removed by hand, emptied, or recorded for another home, it is not preserved state — `uninstall` + // would not touch it either — so AB7307 does not claim it. + await rm(join(destination, 'operator-notes.md')); + const pluginData = join(fixture.home, '.cursor', 'agent-bundle', 'plugin-data', 'doctor-fixture'); + const remnantReceipt = JSON.parse(await readFile(join(destination, installReceiptFile), 'utf8')) as Record; + const recordExpansion = async (recorded: string) => writeFile(join(destination, installReceiptFile), JSON.stringify({ + ...remnantReceipt, + cursorExpansion: { documents: { 'mcp.json': '{}\n' }, pluginData: recorded, pluginRoot: destination }, + })); + const remnantMessages = async () => hostReport(await doctor(), 'cursor').diagnostics + .filter((entry) => entry.code === 'AB7307').map((entry) => entry.message); + await recordExpansion(pluginData); + await mkdir(pluginData, { recursive: true }); + await writeFile(join(pluginData, 'cache.sqlite'), 'durable\n'); + const withData = await remnantMessages(); + expect(withData.length).toBeGreaterThan(0); + expect(withData.every((message) => message.includes(`holds only preserved runtime state (the PLUGIN_DATA directory ${pluginData})`))).toBe(true); + await rm(join(pluginData, 'cache.sqlite')); + expect((await remnantMessages()).every((message) => !message.includes('PLUGIN_DATA'))).toBe(true); + await rm(pluginData, { recursive: true }); + expect((await remnantMessages()).every((message) => !message.includes('PLUGIN_DATA'))).toBe(true); + const elsewhere = join(fixture.root, 'other-home', '.cursor', 'agent-bundle', 'plugin-data', 'doctor-fixture'); + await mkdir(elsewhere, { recursive: true }); + await writeFile(join(elsewhere, 'cache.sqlite'), 'foreign\n'); + await recordExpansion(elsewhere); + expect((await remnantMessages()).every((message) => !message.includes('PLUGIN_DATA'))).toBe(true); } finally { await fixture.cleanup(); } diff --git a/packages/agent-bundle/tests/install-surface.test.ts b/packages/agent-bundle/tests/install-surface.test.ts index cb3d4f386..899177eaa 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -285,6 +285,44 @@ it('emitted install.mjs expands Agent Plugins placeholders for the Cursor copy o expect(await stat(pluginData).catch(() => undefined)).toBeUndefined(); expect(await stat(join(home, '.cursor', 'agent-bundle')).catch(() => undefined)).toBeUndefined(); expect(await stat(destination).catch(() => undefined)).toBeUndefined(); + // A kept PLUGIN_DATA directory later emptied by hand leaves a remnant guarding nothing: the next default run prunes + // the empty directory with its agent-bundle parents and consumes the remnant instead of the keep-data no-op. + await writeFile(join(bundle, 'mcp.json'), mcpText); + expect(await run(installer, [], home)).toMatchObject({ code: 0, stderr: '' }); + await writeFile(join(pluginData, 'cache.sqlite'), 'durable\n'); + expect((await run(installer, ['--uninstall'], home)).stdout).toContain('Remnant receipt:'); + expect((await run(installer, ['--uninstall'], home)).stdout).toContain('Not installed install-fixture@1.2.3'); + await rm(join(pluginData, 'cache.sqlite')); + const emptiedByHand = await run(installer, ['--uninstall'], home); + expect(emptiedByHand).toMatchObject({ code: 0, stderr: '' }); + expect(emptiedByHand.stdout).toContain('Uninstalled install-fixture@1.2.3'); + expect(emptiedByHand.stdout).toContain(`the installer-created PLUGIN_DATA directory ${pluginData} is empty and is pruned`); + expect(emptiedByHand.stdout).not.toContain('Remnant receipt:'); + expect(await stat(pluginData).catch(() => undefined)).toBeUndefined(); + expect(await stat(join(home, '.cursor', 'agent-bundle')).catch(() => undefined)).toBeUndefined(); + expect(await stat(destination).catch(() => undefined)).toBeUndefined(); + // A symlinked agent-bundle or plugin-data ancestor would let a recursive purge of the leaf follow it outside the + // Cursor home: refused before anything is read or removed. + expect(await run(installer, [], home)).toMatchObject({ code: 0, stderr: '' }); + const outside = join(root, 'outside-home'); + await mkdir(join(outside, 'plugin-data', 'install-fixture'), { recursive: true }); + await writeFile(join(outside, 'plugin-data', 'install-fixture', 'cache.sqlite'), 'elsewhere\n'); + await rm(join(home, '.cursor', 'agent-bundle'), { force: true, recursive: true }); + await symlink(outside, join(home, '.cursor', 'agent-bundle')); + const linkedParent = await run(installer, ['--uninstall', '--purge-data', '--confirm-purge'], home); + expect(linkedParent.code).toBe(1); + expect(linkedParent.stderr).toContain('Refusing unsupported filesystem entry'); + expect(await readFile(join(outside, 'plugin-data', 'install-fixture', 'cache.sqlite'), 'utf8')).toBe('elsewhere\n'); + await rm(join(home, '.cursor', 'agent-bundle')); + await mkdir(join(home, '.cursor', 'agent-bundle')); + await symlink(join(outside, 'plugin-data'), join(home, '.cursor', 'agent-bundle', 'plugin-data')); + const linkedChild = await run(installer, ['--uninstall'], home); + expect(linkedChild.code).toBe(1); + expect(linkedChild.stderr).toContain('Refusing unsupported filesystem entry'); + expect(await readFile(join(outside, 'plugin-data', 'install-fixture', 'cache.sqlite'), 'utf8')).toBe('elsewhere\n'); + await rm(join(home, '.cursor', 'agent-bundle'), { force: true, recursive: true }); + await rm(outside, { force: true, recursive: true }); + expect(await run(installer, ['--uninstall'], home)).toMatchObject({ code: 0, stderr: '' }); // Fresh install, nothing written to PLUGIN_DATA: the default uninstall prunes it (and the empty agent-bundle parents). await writeFile(join(bundle, 'mcp.json'), mcpText); expect(await run(installer, [], home)).toMatchObject({ code: 0, stderr: '' }); @@ -748,18 +786,18 @@ it('emitted install.mjs --uninstall mirrors the core lifecycle: plan, receipt-ow expect(purged.stdout).toContain(join(destination, 'state')); expect(diffTreeSnapshots(before, await snapshotTree(home))).toEqual({ added: [], changed: [], removed: [] }); - // A remnant whose state/ was removed by hand: a keep-data rerun is still the no-op, an explicit purge consumes - // the remnant receipt and prunes the host directories it recorded. + // A remnant whose state/ was removed by hand guards nothing: the keep-data no-op applies only while the preserved + // data is still there, so a default rerun consumes the remnant receipt and prunes the host directories it recorded. await run(installer, [], home); await mkdir(join(destination, 'state')); await writeFile(join(destination, 'state', 'plugin.sqlite'), 'durable\n'); await run(installer, ['--uninstall'], home); - await rm(join(destination, 'state'), { recursive: true }); expect((await run(installer, ['--uninstall'], home)).stdout).toContain('Not installed install-fixture@1.2.3'); - const emptyRemnant = await run(installer, ['--uninstall', '--purge-data', '--confirm-purge'], home); + await rm(join(destination, 'state'), { recursive: true }); + const emptyRemnant = await run(installer, ['--uninstall'], home); expect(emptyRemnant).toMatchObject({ code: 0, stderr: '' }); expect(emptyRemnant.stdout).toContain('Uninstalled install-fixture@1.2.3'); - expect(emptyRemnant.stdout).toContain('Data (purge): absent'); + expect(emptyRemnant.stdout).toContain('Data (keep): absent'); expect(emptyRemnant.stdout).not.toContain('Remnant receipt:'); expect(diffTreeSnapshots(before, await snapshotTree(home))).toEqual({ added: [], changed: [], removed: [] }); diff --git a/packages/agent-bundle/tests/uninstall.test.ts b/packages/agent-bundle/tests/uninstall.test.ts index 7c65cd864..9f62a762c 100644 --- a/packages/agent-bundle/tests/uninstall.test.ts +++ b/packages/agent-bundle/tests/uninstall.test.ts @@ -266,22 +266,24 @@ it('keeps created host directories receipt-owned across a --keep-data cycle in a expect(purged.removed.directories).toEqual(expect.arrayContaining([join(cursorRoot, 'plugins', 'local'), join(cursorRoot, 'plugins')])); expect(diffTreeSnapshots(before, await snapshotTree(fixture.home))).toEqual({ added: [], changed: [], removed: [] }); - // A remnant whose state/ was later removed by hand is still receipt-owned: an explicit purge consumes it and - // prunes the recorded host directories instead of taking the keep-data no-op path. + // A remnant whose state/ was later removed by hand guards nothing: the keep-data no-op applies only while the + // preserved data is still there, so a default rerun consumes the remnant (receipt, empty root, recorded host + // directories) exactly as an explicit purge would, and `--plan` says so first. await installBundle(options); await mkdir(join(destination, 'state')); await writeFile(join(destination, 'state', 'plugin.sqlite'), 'durable\n'); expect((await uninstallBundle(options)).remnantReceipt).toBe(join(destination, installReceiptFile)); await rm(join(destination, 'state'), { force: true, recursive: true }); - expect(await uninstallBundle(options)).toMatchObject({ receipt: { status: 'remnant' }, state: 'not-installed' }); - const emptyPlan = await uninstallBundle({ ...options, confirmPurge: true, plan: true, purgeData: true }); + const emptyPlan = await uninstallBundle({ ...options, plan: true }); expect(emptyPlan).toMatchObject({ - data: { outcome: 'absent', policy: 'purge' }, + data: { outcome: 'absent', policy: 'keep' }, + receipt: { status: 'consumed' }, + registrations: [{ action: 'already-absent', kind: 'cursor-local-plugin' }], removed: { directories: [destination, join(cursorRoot, 'plugins', 'local'), join(cursorRoot, 'plugins')], files: [join(destination, installReceiptFile)] }, state: 'planned', }); expect(emptyPlan.remnantReceipt).toBeUndefined(); - const consumed = await uninstallBundle({ ...options, confirmPurge: true, purgeData: true }); + const consumed = await uninstallBundle(options); expect(consumed).toMatchObject({ data: { outcome: 'absent' }, removed: emptyPlan.removed, state: 'uninstalled' }); expect(consumed.remnantReceipt).toBeUndefined(); expect(diffTreeSnapshots(before, await snapshotTree(fixture.home))).toEqual({ added: [], changed: [], removed: [] }); @@ -317,6 +319,40 @@ it('keeps created host directories receipt-owned across a --keep-data cycle in a expect(purgedData.remnantReceipt).toBeUndefined(); expect(diffTreeSnapshots(before, await snapshotTree(fixture.home))).toEqual({ added: [], changed: [], removed: [] }); + // The same for a kept PLUGIN_DATA directory later emptied by hand: the remnant no longer guards data, so the next + // default run prunes the empty directory with its agent-bundle parents and consumes the remnant. + await withExpansion(pluginData); + await mkdir(join(pluginData, 'cache'), { recursive: true }); + await writeFile(join(pluginData, 'cache', 'index.json'), '{}\n'); + expect(await uninstallBundle(options)).toMatchObject({ data: { outcome: 'kept' }, remnantReceipt: join(destination, installReceiptFile) }); + await rm(join(pluginData, 'cache'), { force: true, recursive: true }); + const emptiedByHand = await uninstallBundle(options); + expect(emptiedByHand).toMatchObject({ data: { detail: expect.stringContaining('is empty and is pruned'), outcome: 'absent' }, receipt: { status: 'consumed' }, state: 'uninstalled' }); + expect(emptiedByHand.removed.directories).toEqual(expect.arrayContaining([pluginData, join(cursorRoot, 'agent-bundle', 'plugin-data'), join(cursorRoot, 'agent-bundle'), destination])); + expect(emptiedByHand.remnantReceipt).toBeUndefined(); + expect(diffTreeSnapshots(before, await snapshotTree(fixture.home))).toEqual({ added: [], changed: [], removed: [] }); + + // A symlinked ancestor on the way to PLUGIN_DATA (agent-bundle or plugin-data) would let a recursive purge of the + // leaf follow it outside the Cursor home: refused before anything is read or removed, whichever link it is. + const outside = join(fixture.cleanupRoot, 'outside-home'); + await mkdir(join(outside, 'plugin-data', 'uninstall-fixture'), { recursive: true }); + await writeFile(join(outside, 'plugin-data', 'uninstall-fixture', 'cache.sqlite'), 'elsewhere\n'); + await withExpansion(pluginData); + await symlink(outside, join(cursorRoot, 'agent-bundle')); + const linkedParent = await failureOf(uninstallBundle({ ...options, confirmPurge: true, purgeData: true })); + expect(linkedParent.diagnostics[0]).toMatchObject({ code: 'AB7007', target: 'cursor' }); + expect(await readFile(join(outside, 'plugin-data', 'uninstall-fixture', 'cache.sqlite'), 'utf8')).toBe('elsewhere\n'); + await rm(join(cursorRoot, 'agent-bundle')); + await mkdir(join(cursorRoot, 'agent-bundle')); + await symlink(join(outside, 'plugin-data'), join(cursorRoot, 'agent-bundle', 'plugin-data')); + const linkedChild = await failureOf(uninstallBundle(options)); + expect(linkedChild.diagnostics[0]).toMatchObject({ code: 'AB7007', target: 'cursor' }); + expect(await readFile(join(outside, 'plugin-data', 'uninstall-fixture', 'cache.sqlite'), 'utf8')).toBe('elsewhere\n'); + await rm(join(cursorRoot, 'agent-bundle'), { force: true, recursive: true }); + await rm(outside, { force: true, recursive: true }); + expect(await uninstallBundle(options)).toMatchObject({ state: 'uninstalled' }); + expect(diffTreeSnapshots(before, await snapshotTree(fixture.home))).toEqual({ added: [], changed: [], removed: [] }); + await withExpansion(pluginData); await mkdir(pluginData, { recursive: true }); const emptyData = await uninstallBundle({ ...options, plan: true }); From cd9cc03f18df882d8ef112ff76912480002359c1 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 03:46:27 +0000 Subject: [PATCH 2/4] changeset: PLUGIN_DATA uninstall hardening follow-up --- .changeset/101-plugin-data-followups.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/101-plugin-data-followups.md diff --git a/.changeset/101-plugin-data-followups.md b/.changeset/101-plugin-data-followups.md new file mode 100644 index 000000000..727deba1f --- /dev/null +++ b/.changeset/101-plugin-data-followups.md @@ -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 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. Follow-up to the review threads on #452. (#513) From 05ac24cb95ba8ea88b1fc8f02b1399eaac39e692 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 03:46:40 +0000 Subject: [PATCH 3/4] changeset: attribute to #519 --- .changeset/101-plugin-data-followups.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/101-plugin-data-followups.md b/.changeset/101-plugin-data-followups.md index 727deba1f..8d39e2ee3 100644 --- a/.changeset/101-plugin-data-followups.md +++ b/.changeset/101-plugin-data-followups.md @@ -2,4 +2,4 @@ "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 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. Follow-up to the review threads on #452. (#513) +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 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. Follow-up to the review threads on #452. (#519) From 571373fb71a974be9afee492883fa7ec27ca8d59 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 04:07:37 +0000 Subject: [PATCH 4/4] fix(uninstall): treat an emptied state/ as absent (pruned with the exhausted remnant) and stop Doctor's AB7307 from inventing state/ for a remnant whose preserved data is gone --- .changeset/101-plugin-data-followups.md | 2 +- docs/diagnostics.md | 15 +++++-- packages/agent-bundle/src/install/doctor.ts | 39 +++++++++++++------ packages/agent-bundle/src/install/surface.ts | 9 +++-- .../agent-bundle/src/install/uninstall.ts | 18 +++++++-- packages/agent-bundle/tests/doctor.test.ts | 19 ++++++++- .../tests/install-surface.test.ts | 12 ++++++ packages/agent-bundle/tests/uninstall.test.ts | 17 ++++++++ 8 files changed, 106 insertions(+), 25 deletions(-) diff --git a/.changeset/101-plugin-data-followups.md b/.changeset/101-plugin-data-followups.md index 8d39e2ee3..4e1fea68d 100644 --- a/.changeset/101-plugin-data-followups.md +++ b/.changeset/101-plugin-data-followups.md @@ -2,4 +2,4 @@ "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 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. Follow-up to the review threads on #452. (#519) +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) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index dc8289e80..d0324ded4 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -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`) @@ -1014,9 +1018,12 @@ root, the remnant receipt written there stays in place (`receipt.status: 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, 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. +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 | | --- | --- | --- | --- | diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index 3a0822c63..267d4f30a 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -554,28 +554,44 @@ const preservedPluginData = async (pluginRoot: string, receipt: InstallReceipt | /** * 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, receipt: InstallReceipt | undefined): Promise => { - 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 => { + 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); 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', @@ -944,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)); + diagnostics.push(await remnantDiagnostic(`Cursor plugin entry ${JSON.stringify(path)}`, path, remnantReceipt)); findings.push({ ...(durableState === undefined ? {} : { durableState }), entry, @@ -1841,7 +1857,6 @@ const cursorBundle = async ( diagnostics: freezeDiagnostics([await remnantDiagnostic( `Cursor destination ${destination} (${identity.name}@${identity.version})`, destination, - stateOnly, comparison.receipt, )]), finding: Object.freeze({ diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index 66ce42aa8..115819b50 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -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.', @@ -405,7 +407,7 @@ const cursorUninstallerSource = (): readonly string[] => [ ' }', " 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}`;", @@ -417,6 +419,7 @@ 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);', diff --git a/packages/agent-bundle/src/install/uninstall.ts b/packages/agent-bundle/src/install/uninstall.ts index 5f700dcdc..c88154922 100644 --- a/packages/agent-bundle/src/install/uninstall.ts +++ b/packages/agent-bundle/src/install/uninstall.ts @@ -436,6 +436,8 @@ const realPluginDataDirectory = async (cursorRoot: string, pluginData: string): interface CursorLocalData { /** Installer-created `PLUGIN_DATA` directory that nothing wrote to: pruned like a created host directory, never "data". */ readonly emptyPluginData?: string; + /** A `state/` directory holding nothing: not durable state, so it is pruned rather than kept alive as a remnant. */ + readonly emptyState?: string; /** Whether any durable state (state/ or a written PLUGIN_DATA) exists. */ readonly present: boolean; /** A written `PLUGIN_DATA` directory kept by `--keep-data`: it lives outside the plugin root, so the root must survive to carry it. */ @@ -453,9 +455,14 @@ const cursorLocalData = async ( const stateDirectory = join(destination, 'state'); const paths: string[] = []; const kinds: string[] = []; + let emptyState: string | undefined; if (await realDirectory(stateDirectory, 'cursor') !== undefined) { - paths.push(stateDirectory); - kinds.push('state/ (state kernel, notices journal)'); + if ((await readdir(stateDirectory)).length === 0) { + emptyState = stateDirectory; + } else { + paths.push(stateDirectory); + kinds.push('state/ (state kernel, notices journal)'); + } } // The receipt's cursorExpansion records the PLUGIN_DATA directory the installer created for this copy; only the // directory at this home's own plugin-data location is receipt-owned — a recorded path elsewhere is left alone. @@ -481,9 +488,12 @@ const cursorLocalData = async ( if (paths.length === 0) { return { ...(emptyPluginData === undefined ? {} : { emptyPluginData }), + ...(emptyState === undefined ? {} : { emptyState }), present: false, report: Object.freeze({ - detail: `No durable runtime state exists (no state/ under the installed plugin root${ + detail: `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}`, outcome: 'absent', @@ -495,6 +505,7 @@ const cursorLocalData = async ( const retainedPluginData = policy === 'purge' ? undefined : paths.find((path) => path === expected); return { ...(emptyPluginData === undefined ? {} : { emptyPluginData }), + ...(emptyState === undefined ? {} : { emptyState }), present: true, report: Object.freeze({ detail: policy === 'purge' @@ -573,6 +584,7 @@ const uninstallCursorLocal = async ( // The installer created PLUGIN_DATA and its agent-bundle parents; once empty they go too — never while // receipts, marketplaces, or another plugin's data keep them alive. ...(data.emptyPluginData === undefined ? [] : [data.emptyPluginData]), + ...(data.emptyState === undefined ? [] : [data.emptyState]), ...(pluginDataRecorded ? [join(cursorRoot, 'agent-bundle', 'plugin-data'), join(cursorRoot, 'agent-bundle')] : []), ]; const ownedDirectories = new Set(ownership.directories); diff --git a/packages/agent-bundle/tests/doctor.test.ts b/packages/agent-bundle/tests/doctor.test.ts index 6a7fa17ac..95453c079 100644 --- a/packages/agent-bundle/tests/doctor.test.ts +++ b/packages/agent-bundle/tests/doctor.test.ts @@ -2213,10 +2213,25 @@ it('explains a Cursor directory holding only preserved runtime state instead of const withData = await remnantMessages(); expect(withData.length).toBeGreaterThan(0); expect(withData.every((message) => message.includes(`holds only preserved runtime state (the PLUGIN_DATA directory ${pluginData})`))).toBe(true); + // Emptied, removed, or foreign, the directory is not claimed — and with no state/ either, Doctor does not invent + // one: the remnant is reported as exhausted, with the default `uninstall` that consumes it as the recovery. await rm(join(pluginData, 'cache.sqlite')); - expect((await remnantMessages()).every((message) => !message.includes('PLUGIN_DATA'))).toBe(true); + const exhausted = hostReport(await doctor(), 'cursor').diagnostics.filter((entry) => entry.code === 'AB7307'); + expect(exhausted.length).toBeGreaterThan(0); + for (const entry of exhausted) { + expect(entry.message).toContain('holds only the remnant receipt of an earlier `uninstall --keep-data` whose preserved runtime state has since been removed'); + expect(entry.message).not.toContain('state/'); + expect(entry.message).not.toContain('PLUGIN_DATA'); + expect(entry.recovery).toContain('to consume the remnant'); + } await rm(pluginData, { recursive: true }); - expect((await remnantMessages()).every((message) => !message.includes('PLUGIN_DATA'))).toBe(true); + expect((await remnantMessages()).every((message) => !message.includes('PLUGIN_DATA') && !message.includes('state/'))).toBe(true); + // An emptied state/ directory left behind is not preserved state either. + await mkdir(join(destination, 'state')); + expect((await remnantMessages()).every((message) => message.includes('whose preserved runtime state has since been removed'))).toBe(true); + await writeFile(join(destination, 'state', 'plugin.sqlite'), 'durable\n'); + expect((await remnantMessages()).every((message) => message.includes('holds only preserved runtime state (state/)'))).toBe(true); + await rm(join(destination, 'state'), { recursive: true }); const elsewhere = join(fixture.root, 'other-home', '.cursor', 'agent-bundle', 'plugin-data', 'doctor-fixture'); await mkdir(elsewhere, { recursive: true }); await writeFile(join(elsewhere, 'cache.sqlite'), 'foreign\n'); diff --git a/packages/agent-bundle/tests/install-surface.test.ts b/packages/agent-bundle/tests/install-surface.test.ts index 899177eaa..efde9d134 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -800,6 +800,18 @@ it('emitted install.mjs --uninstall mirrors the core lifecycle: plan, receipt-ow expect(emptyRemnant.stdout).toContain('Data (keep): absent'); expect(emptyRemnant.stdout).not.toContain('Remnant receipt:'); expect(diffTreeSnapshots(before, await snapshotTree(home))).toEqual({ added: [], changed: [], removed: [] }); + // A state/ emptied by hand (directory left behind) is not durable state either: pruned with the exhausted remnant. + await run(installer, [], home); + await mkdir(join(destination, 'state')); + await writeFile(join(destination, 'state', 'plugin.sqlite'), 'durable\n'); + await run(installer, ['--uninstall'], home); + await rm(join(destination, 'state', 'plugin.sqlite')); + const emptiedState = await run(installer, ['--uninstall'], home); + expect(emptiedState).toMatchObject({ code: 0, stderr: '' }); + expect(emptiedState.stdout).toContain('Uninstalled install-fixture@1.2.3'); + expect(emptiedState.stdout).toContain('state/ under the installed plugin root is empty and is pruned'); + expect(emptiedState.stdout).not.toContain('Remnant receipt:'); + expect(diffTreeSnapshots(before, await snapshotTree(home))).toEqual({ added: [], changed: [], removed: [] }); // Modified owned content: refused with the hash comparison until --force. await run(installer, [], home); diff --git a/packages/agent-bundle/tests/uninstall.test.ts b/packages/agent-bundle/tests/uninstall.test.ts index 9f62a762c..4e03c0346 100644 --- a/packages/agent-bundle/tests/uninstall.test.ts +++ b/packages/agent-bundle/tests/uninstall.test.ts @@ -288,6 +288,23 @@ it('keeps created host directories receipt-owned across a --keep-data cycle in a expect(consumed.remnantReceipt).toBeUndefined(); expect(diffTreeSnapshots(before, await snapshotTree(fixture.home))).toEqual({ added: [], changed: [], removed: [] }); + // A state/ directory emptied by hand (the directory itself left behind) is not durable state either: the remnant + // is exhausted, the empty state/ is pruned with the root, and the home is byte-identical again. + await installBundle(options); + await mkdir(join(destination, 'state')); + await writeFile(join(destination, 'state', 'plugin.sqlite'), 'durable\n'); + expect((await uninstallBundle(options)).remnantReceipt).toBe(join(destination, installReceiptFile)); + await rm(join(destination, 'state', 'plugin.sqlite')); + const emptiedState = await uninstallBundle(options); + expect(emptiedState).toMatchObject({ + data: { detail: expect.stringContaining('state/ under the installed plugin root is empty and is pruned'), outcome: 'absent' }, + receipt: { status: 'consumed' }, + state: 'uninstalled', + }); + expect(emptiedState.removed.directories).toEqual([join(destination, 'state'), destination, join(cursorRoot, 'plugins', 'local'), join(cursorRoot, 'plugins')]); + expect(emptiedState.remnantReceipt).toBeUndefined(); + expect(diffTreeSnapshots(before, await snapshotTree(fixture.home))).toEqual({ added: [], changed: [], removed: [] }); + // A receipt recording a PLUGIN_DATA expansion (written by the emitted install.mjs for an Agent Plugins pack): // the directory is receipt-owned durable state outside the plugin root. Written → kept behind a remnant that // carries the expansion (the root survives to own it), purged only when confirmed; empty → pruned with its