diff --git a/.changeset/legacy-state-purge-guard.md b/.changeset/legacy-state-purge-guard.md new file mode 100644 index 000000000..7986dd7fe --- /dev/null +++ b/.changeset/legacy-state-purge-guard.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Make `uninstall --purge-data` retain state roots absent from legacy receipts and clarify `AB7332` ownership guidance (#689). diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 0e6f9a7c4..658f0f9b5 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -1354,11 +1354,16 @@ canonical code root, declared environment, and execution directory. It reports the servers, source, receipt ownership, current purgeability, existence, and writability separately from the pre-#640 in-tree location. A runtime location without matching receipt ownership remains visible but is never deletion -authority. +authority. In particular, a supported legacy receipt with no recorded state +location cannot turn the current environment or home into purge authority; +Doctor reports that observed root as unrecorded and retained. When a +compatibility receipt does record `stateRoot`, Doctor lists that historical +root separately if the current environment resolves elsewhere and marks only +the receipt-recorded derived root purgeable. | Code | Severity | Trigger | | --- | --- | --- | -| `AB7332` | info | `/state` still exists while the installed artifact resolves framework state elsewhere. Move any state that must be retained, or use `uninstall --purge-data --confirm-purge` to remove both roots. | +| `AB7332` | info | `/state` still exists while the installed artifact resolves framework state elsewhere. Move any state that must be retained, or use `uninstall --purge-data --confirm-purge` to remove the in-tree root plus only those effective roots whose receipt ownership is currently purgeable; unrecorded roots remain retained. | ## Read-only Doctor marketplace sources (`AB7333`) diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index 253c48406..858a393dc 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -341,6 +341,9 @@ grace period; a purge also removes external framework state, web-data, `state/`, and `plugins/data//`); Codex reports external state as `kept` / `purged`, while in-tree `state/` is removed by the host and cannot be kept (codex-cli 0.147.0 has no keep-data option). +An older receipt that records no state location never makes a root derived +from the current environment or home purgeable; it is reported unproven and +retained, including after a keep-data cycle. `--plan` reports the same exact paths and host verbs without opening a writer. A missing receipt (`AB7009`) or an owned-content, version, or `HEAD` mismatch (`AB7007`) is refused unless `--force`; a receipt or manifest naming another diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index 3c63ee167..5306802d1 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -79,6 +79,7 @@ import { cursorMarketplacePluginPath, cursorMarketplaceRoot } from './cursor-mar import { bundleInventory, installedBundleInventory, readBundleIdentity, type PluginIdentity } from './identity.ts'; import { inspectInstalledStateOwnership, + isRecordedDerivedStateRoot, resolveInstalledStateRoots, } from './state-root.ts'; @@ -666,13 +667,22 @@ const inspectInstalledDurableState = async ( if (current === undefined) grouped.set(location.root, { servers: [location.server], source: location.source }); else current.servers.push(location.server); } + const recordedLegacyRoot = receipt?.state === undefined ? receipt?.stateRoot : undefined; + if (recordedLegacyRoot !== undefined && !grouped.has(recordedLegacyRoot.root)) { + grouped.set(recordedLegacyRoot.root, { + servers: [], + source: recordedLegacyRoot.source === 'derived' ? 'derived' : 'declared', + }); + } const effectiveAll: DoctorDurableStateReport[] = []; for (const [root, current] of grouped) { const recorded = receipt?.state?.roots.find((candidate) => candidate.root === root); + const legacyPurgeable = receipt?.state === undefined && + isRecordedDerivedStateRoot(receipt?.stateRoot, root); const decision = recorded === undefined || receipt?.state === undefined ? undefined : await inspectInstalledStateOwnership(receipt.state, recorded); - const ownership = recorded?.ownership.kind ?? 'unrecorded'; + const ownership = recorded?.ownership.kind ?? (legacyPurgeable ? 'derived' : 'unrecorded'); const inspected = await inspectDurableState( root, current.source === 'derived' ? 'derived' : 'native', @@ -684,7 +694,7 @@ const inspectInstalledDurableState = async ( ...(recorded?.ownership.kind === 'unowned' ? { ownershipReason: recorded.ownership.reason } : decision?.reason === undefined ? {} : { ownershipReason: decision.reason }), - purgeable: decision?.action === 'purge', + purgeable: decision?.action === 'purge' || legacyPurgeable, servers: Object.freeze(current.servers), })); } @@ -716,7 +726,9 @@ const inspectInstalledDurableState = async ( const legacyDiagnostic = diagnostic( 'AB7332', `Legacy durable state remains at ${JSON.stringify(legacyRoot)} while this install resolves framework state to ${JSON.stringify(effective.directory)}.`, - 'Run `agent-bundle uninstall --purge-data --confirm-purge` for this install to remove both roots, or move required pre-#640 data before deleting the legacy directory.', + reportedAll.some((entry) => entry.purgeable) + ? 'Run `agent-bundle uninstall --purge-data --confirm-purge` to remove the legacy in-tree root and all receipt-owned effective roots; unrecorded effective roots remain retained. Move required data before deleting any other directory by hand.' + : `Run \`agent-bundle uninstall --purge-data --confirm-purge\` to remove the legacy in-tree root; it retains the ${effective.ownership} effective root because the receipt does not prove exclusive ownership. Move required data before deleting either directory by hand.`, 'info', host, ); diff --git a/packages/agent-bundle/src/install/state-root.ts b/packages/agent-bundle/src/install/state-root.ts index 515a15fe8..b99353603 100644 --- a/packages/agent-bundle/src/install/state-root.ts +++ b/packages/agent-bundle/src/install/state-root.ts @@ -21,6 +21,12 @@ export interface InstalledStateRoot { readonly source: 'derived' | 'native'; } +/** Compatibility receipts authorize a derived purge only when they record that exact root. */ +export const isRecordedDerivedStateRoot = ( + recorded: InstalledStateRoot | undefined, + root: string, +): boolean => recorded?.source === 'derived' && recorded.root === root; + export interface InstalledStateLocation { readonly root?: string; readonly server: string; diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index ab02f025d..58bda3456 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -199,8 +199,9 @@ const cursorInstructions = (model: NormalizedPlugin): string[] => [ 'runtime state under `state/` (state kernel, notices journal) — and, for an Agent Plugins pack with a stdio', 'server, the `~/.cursor/agent-bundle/plugin-data/` directory the receipt records as `PLUGIN_DATA` — is kept', 'unless `--purge-data --confirm-purge` is passed (a kept data directory leaves a remnant receipt behind so a later', - 'purge still finds it; an empty one is pruned); unowned files are left in place and listed. A directory without a', - 'receipt is refused unless', + 'purge still finds it; an empty one is pruned); unowned files are left in place and listed. A supported older', + 'receipt with no recorded state location retains the current environment\'s default as unproven; a keep-data run', + 'cannot turn that observation into later purge authority. A directory without a receipt is refused unless', '`--force` (which removes a pre-receipt legacy copy by its inventory); owned content that no longer matches', 'the receipt is refused unless `--force`; a directory that is not this plugin\'s install is always refused.', 'A second run is a `Not installed` no-op. With the optional `agent-bundle` CLI,', @@ -490,7 +491,7 @@ const cursorUninstallerSource = (): readonly string[] => [ ' let metadata;', " try { metadata = await lstat(fallbackStateDirectory); } catch (error) { if (error?.code !== 'ENOENT') throw error; }", " if (metadata?.isDirectory()) {", - " if (receipt !== undefined && fallbackStateSource === 'derived') ownedStatePaths.push(fallbackStateDirectory);", + " if (receipt?.stateRoot?.root === fallbackStateDirectory && fallbackStateSource === 'derived') ownedStatePaths.push(fallbackStateDirectory);", " else retainedState.push({ path: fallbackStateDirectory, reason: 'unproven' });", " }", ' }', @@ -541,8 +542,8 @@ const cursorUninstallerSource = (): readonly string[] => [ ' : purgeData', " ? `${dataPaths.length === 0 ? 'No owned durable runtime state is removed.' : `Durable runtime state — ${dataKinds.join(' and ')} — is removed (--purge-data --confirm-purge).`}${retainedStateNote}${foreignNote}`", " : `Durable runtime state${dataKinds.length === 0 ? '' : ` — ${dataKinds.join(' and ')}`} — is kept; pass --purge-data --confirm-purge to remove owned roots.${retainedStateNote}${foreignNote}`;", - ' // External state kept by --keep-data needs the remnant receipt and canonical install path so a later purge', - ' // derives and removes the same root even though no plugin content remains.', + ' // External state kept by --keep-data needs the remnant receipt and recorded ownership so a later purge', + ' // removes the same root even though no plugin content remains.', ' const keepRoot = !purgeData && [...dataPaths, ...retainedState.map((entry) => entry.path)].some((path) => path !== stateDirectory);', ' const directories = [', ' ...ownedDirectories.map((directory) => join(destination, directory)),', diff --git a/packages/agent-bundle/src/install/uninstall.ts b/packages/agent-bundle/src/install/uninstall.ts index 7b476a124..43fed227f 100644 --- a/packages/agent-bundle/src/install/uninstall.ts +++ b/packages/agent-bundle/src/install/uninstall.ts @@ -61,7 +61,12 @@ import { type InstallRegistration, type StoredInstallReceipt, } from './receipt.ts'; -import { inspectInstalledStateOwnership, installedWebDataRoot, resolveInstalledStateRoot } from './state-root.ts'; +import { + inspectInstalledStateOwnership, + installedWebDataRoot, + isRecordedDerivedStateRoot, + resolveInstalledStateRoot, +} from './state-root.ts'; /** * `agent-bundle uninstall ` (#101): the receipt-owned reverse of @@ -483,7 +488,7 @@ const cursorLocalData = async ( } else { const observed = receipt?.stateRoot ?? await resolveInstalledStateRoot(destination, 'cursor', environment, home); if (observed.root !== stateDirectory && await realDirectory(observed.root, 'cursor') !== undefined) { - if (receipt !== undefined && observed.source === 'derived') { + if (isRecordedDerivedStateRoot(receipt?.stateRoot, observed.root)) { paths.push(observed.root); kinds.push(`derived framework state root ${observed.root}`); } else { @@ -633,8 +638,8 @@ const uninstallCursorLocal = async ( } files.push(...data.emptyStateFiles); if (ownership.receipt !== undefined || await exists(receiptPath)) files.push(receiptPath); - // External state kept by --keep-data needs the remnant receipt and canonical install path so a later purge can - // derive and remove the same root even though no plugin content remains. + // External state kept by --keep-data needs the remnant receipt and recorded ownership so a later purge can + // remove the same root even though no plugin content remains. const keepRoot = policy === 'keep' && [...data.report.paths, ...(data.report.retained ?? []).map((entry) => entry.path)] .some((path) => path !== join(destination, 'state')); @@ -1177,8 +1182,7 @@ const publicHostData = async ( !paths.includes(observed.root) && await realDirectory(observed.root, host) !== undefined ) { - if (receipt !== undefined && observed.source === 'derived') paths.push(observed.root); - else retainedState.push({ path: observed.root, reason: 'unproven' }); + retainedState.push({ path: observed.root, reason: 'unproven' }); } } } diff --git a/packages/agent-bundle/tests/doctor.test.ts b/packages/agent-bundle/tests/doctor.test.ts index 2a1d0a571..d4420ba25 100644 --- a/packages/agent-bundle/tests/doctor.test.ts +++ b/packages/agent-bundle/tests/doctor.test.ts @@ -560,12 +560,12 @@ it('inventories durable SQLite stores and sidecars without opening them', async servers: ['default'], writable: true, }); - expect(report.diagnostics).toEqual(expect.arrayContaining([ - expect.objectContaining({ - code: 'AB7332', - message: expect.stringContaining(legacyStateRoot), - }), - ])); + const legacyDiagnostic = report.diagnostics.find((entry) => entry.code === 'AB7332'); + expect(legacyDiagnostic).toMatchObject({ + message: expect.stringContaining(legacyStateRoot), + recovery: expect.stringContaining('retains the unrecorded effective root'), + }); + expect(legacyDiagnostic?.recovery).not.toContain('both roots'); const human = captureCliTerminal(); const humanCode = await runCli(['doctor'], human.output, { runDoctor: async () => report }); @@ -587,6 +587,114 @@ it('inventories durable SQLite stores and sidecars without opening them', async } }); +it('reports a current-environment legacy state root as unrecorded and retained', async () => { + const fixture = await temporaryDoctor(); + const originalEnvironment = { XDG_STATE_HOME: join(fixture.root, 'original-state-home') }; + const currentEnvironment = { XDG_STATE_HOME: join(fixture.root, 'current-state-home') }; + try { + const bundle = await createBundle(fixture.root, 'cursor'); + await mkdir(join(fixture.home, '.cursor'), { recursive: true }); + await installBundle({ + environment: originalEnvironment, + from: bundle, + home: fixture.home, + host: 'cursor', + }); + const destination = join(fixture.home, '.cursor', 'plugins', 'local', 'doctor-fixture'); + const originalStateRoot = userDataStateRoot(destination, originalEnvironment, fixture.home); + const currentStateRoot = userDataStateRoot(destination, currentEnvironment, fixture.home); + await mkdir(originalStateRoot, { recursive: true }); + await mkdir(currentStateRoot, { recursive: true }); + await writeFile(join(originalStateRoot, 'state.sqlite'), 'original\n'); + await writeFile(join(currentStateRoot, 'unrelated.txt'), 'unrelated\n'); + const receiptPath = join(destination, installReceiptFile); + const receipt = JSON.parse(await readFile(receiptPath, 'utf8')) as Record; + const { + hostDirectories: _hostDirectories, + mode: _mode, + registrations: _registrations, + scope: _scope, + state: _state, + stateRoot: _stateRoot, + updatedAt: _updatedAt, + ...legacy + } = receipt; + await writeFile(receiptPath, JSON.stringify({ + ...legacy, + format: 'agent-bundle-install-receipt/1', + })); + + const report = await runDoctor({ + endpointDirectory: fixture.endpointDirectory, + environment: currentEnvironment, + home: fixture.home, + hosts: ['cursor'], + }); + const finding = hostReport(report, 'cursor').inventory.findings.find( + (entry) => entry.entry === 'doctor-fixture', + ); + expect(finding?.durableState).toMatchObject({ + directory: currentStateRoot, + exists: true, + ownership: 'unrecorded', + purgeable: false, + servers: ['default'], + }); + const human = captureCliTerminal(); + expect(await runCli(['doctor'], human.output, { runDoctor: async () => report })).toBe(0); + expect(human.stdout()).toContain(`state root: ${currentStateRoot} (exists, writable, derived)`); + expect(human.stdout()).toContain('ownership: unrecorded, retained, servers: default'); + + const plan = await uninstallBundle({ + confirmPurge: true, + environment: currentEnvironment, + from: bundle, + home: fixture.home, + host: 'cursor', + plan: true, + purgeData: true, + }); + expect(plan.data).toMatchObject({ + outcome: 'kept', + paths: [], + retained: [{ path: currentStateRoot, reason: 'unproven' }], + }); + + await mkdir(join(destination, 'state')); + await writeFile(join(destination, 'state', 'legacy.sqlite'), 'legacy\n'); + await writeFile(receiptPath, JSON.stringify({ + ...legacy, + format: 'agent-bundle-install-receipt/1', + stateRoot: { root: originalStateRoot, source: 'derived' }, + })); + const recordedReport = await runDoctor({ + endpointDirectory: fixture.endpointDirectory, + environment: currentEnvironment, + home: fixture.home, + hosts: ['cursor'], + }); + const recordedFinding = hostReport(recordedReport, 'cursor').inventory.findings.find( + (entry) => entry.entry === 'doctor-fixture', + ); + expect(recordedFinding?.durableStates).toEqual(expect.arrayContaining([ + expect.objectContaining({ + directory: currentStateRoot, + ownership: 'unrecorded', + purgeable: false, + }), + expect.objectContaining({ + directory: originalStateRoot, + ownership: 'derived', + purgeable: true, + }), + ])); + expect(recordedReport.diagnostics.find((entry) => entry.code === 'AB7332')?.recovery) + .toContain('receipt-owned effective roots'); + } finally { + await fixture.cleanup(); + } +}); + it('reports a missing derived state root and a declared state-root override', async () => { const fixture = await temporaryDoctor(); const pluginRoot = join(fixture.home, '.cursor', 'plugins', 'local', 'configured-state'); diff --git a/packages/agent-bundle/tests/install-surface.test.ts b/packages/agent-bundle/tests/install-surface.test.ts index 3810ac319..8e6b7b3d1 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -5,6 +5,7 @@ import { dirname, join } from 'node:path'; import { promisify } from 'node:util'; import { expect, it } from '@rstest/core'; +import { userDataStateRoot } from '@agent-bundle/runtime'; import { createDefaultRegistry } from '../src/adapters/registry.ts'; import type { TargetArtifactWrite } from '../src/adapters/types.ts'; @@ -501,11 +502,12 @@ const run = async ( installer: string, args: readonly string[], home: string, + environment: Readonly = {}, ): Promise<{ readonly code: number; readonly stderr: string; readonly stdout: string }> => { try { const result = await execFile(process.execPath, [installer, ...args], { cwd: dirname(installer), - env: { ...process.env, HOME: home }, + env: { ...process.env, HOME: home, ...environment }, // A regression that reads a FIFO receipt would otherwise hang the whole suite. timeout: 30_000, }); @@ -845,6 +847,109 @@ it('emitted install.mjs marks new explicit state roots and retains pre-existing } }, 60_000); +it('emitted install.mjs never derives legacy purge ownership from the current environment', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-legacy-state-mjs-')); + const bundle = join(root, 'bundle'); + const home = join(root, 'home'); + const cursorRoot = join(home, '.cursor'); + const destination = join(cursorRoot, 'plugins', 'local', 'install-fixture'); + const installer = join(bundle, 'install.mjs'); + const originalEnvironment = { XDG_STATE_HOME: join(root, 'original-state-home') }; + const currentEnvironment = { XDG_STATE_HOME: join(root, 'current-state-home') }; + const originalStateRoot = userDataStateRoot(destination, originalEnvironment, home); + const currentStateRoot = userDataStateRoot(destination, currentEnvironment, home); + const currentSentinel = join(currentStateRoot, 'unrelated.txt'); + try { + const writes = writesFor('cursor'); + await Promise.all([ + mkdir(join(bundle, '.cursor-plugin'), { recursive: true }), + mkdir(cursorRoot, { recursive: true }), + ]); + await Promise.all([ + writeFile(installer, writes.get('install.mjs') ?? ''), + writeFile(join(bundle, 'INSTALL.md'), writes.get('INSTALL.md') ?? ''), + writeFile(join(bundle, '.cursor-plugin', 'plugin.json'), JSON.stringify({ name: 'install-fixture', version: '1.2.3' })), + writeFile(join(bundle, 'payload.txt'), 'payload\n'), + ]); + expect(await run(installer, [], home, originalEnvironment)).toMatchObject({ code: 0, stderr: '' }); + await mkdir(originalStateRoot, { recursive: true }); + await writeFile(join(originalStateRoot, 'state.sqlite'), 'original\n'); + const receiptPath = join(destination, installReceiptFile); + const receipt = JSON.parse(await readFile(receiptPath, 'utf8')) as Record; + const { + hostDirectories: _hostDirectories, + mode: _mode, + registrations: _registrations, + scope: _scope, + state: _state, + stateRoot: _stateRoot, + updatedAt: _updatedAt, + ...legacy + } = receipt; + await writeFile(receiptPath, JSON.stringify({ + ...legacy, + format: legacyInstallReceiptFormat, + })); + await mkdir(currentStateRoot, { recursive: true }); + await writeFile(currentSentinel, 'unrelated\n'); + + const plan = await run( + installer, + ['--uninstall', '--purge-data', '--confirm-purge', '--plan'], + home, + currentEnvironment, + ); + expect(plan).toMatchObject({ code: 0, stderr: '' }); + expect(plan.stdout).toContain('Data (purge): kept'); + expect(plan.stdout).toContain(`Retained ${currentStateRoot} (unproven)`); + + const kept = await run(installer, ['--uninstall', '--keep-data'], home, currentEnvironment); + expect(kept.stdout).toContain(`Retained ${currentStateRoot} (unproven)`); + const remnant = await readInstallReceipt(destination); + expect(remnant?.state).toBeUndefined(); + expect(remnant?.stateRoot).toBeUndefined(); + + const purged = await run( + installer, + ['--uninstall', '--purge-data', '--confirm-purge'], + home, + currentEnvironment, + ); + expect(purged).toMatchObject({ code: 0, stderr: '' }); + expect(purged.stdout).toContain('Data (purge): kept'); + expect(purged.stdout).toContain(`Retained ${currentStateRoot} (unproven)`); + expect(await readFile(currentSentinel, 'utf8')).toBe('unrelated\n'); + expect(await readFile(join(originalStateRoot, 'state.sqlite'), 'utf8')).toBe('original\n'); + + expect(await run(installer, [], home, originalEnvironment)).toMatchObject({ code: 0, stderr: '' }); + const currentReceipt = JSON.parse(await readFile(receiptPath, 'utf8')) as Record; + const { state: _currentState, ...recordedLegacy } = currentReceipt; + await writeFile(receiptPath, JSON.stringify({ + ...recordedLegacy, + stateRoot: { root: originalStateRoot, source: 'derived' }, + })); + const recordedPlan = await run( + installer, + ['--uninstall', '--purge-data', '--confirm-purge', '--plan'], + home, + currentEnvironment, + ); + expect(recordedPlan.stdout).toContain('Data (purge): purged'); + expect(recordedPlan.stdout).toContain(originalStateRoot); + expect(recordedPlan.stdout).not.toContain(currentStateRoot); + expect(await run( + installer, + ['--uninstall', '--purge-data', '--confirm-purge'], + home, + currentEnvironment, + )).toMatchObject({ code: 0, stderr: '' }); + await expect(readFile(join(originalStateRoot, 'state.sqlite'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); + expect(await readFile(currentSentinel, 'utf8')).toBe('unrelated\n'); + } finally { + await rm(root, { force: true, recursive: true }); + } +}, 60_000); + it('emitted install.mjs --uninstall mirrors the core lifecycle: plan, receipt-owned removal, data policy, refusals', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-uninstall-mjs-')); const bundle = join(root, 'bundle'); diff --git a/packages/agent-bundle/tests/uninstall.test.ts b/packages/agent-bundle/tests/uninstall.test.ts index 5e8a3d558..67a5f66a3 100644 --- a/packages/agent-bundle/tests/uninstall.test.ts +++ b/packages/agent-bundle/tests/uninstall.test.ts @@ -944,6 +944,111 @@ it('consumes a migrated format/1 Cursor receipt without a crash', async () => { } }); +it('never derives legacy receipt purge ownership from the current environment', async () => { + const fixture = await createFixture('cursor'); + const cursorRoot = join(fixture.home, '.cursor'); + await mkdir(join(cursorRoot, 'plugins', 'local'), { recursive: true }); + const destination = join(cursorRoot, 'plugins', 'local', 'uninstall-fixture'); + const originalEnvironment = { XDG_STATE_HOME: join(fixture.cleanupRoot, 'original-state-home') }; + const currentEnvironment = { XDG_STATE_HOME: join(fixture.cleanupRoot, 'current-state-home') }; + const options = { from: fixture.bundleRoot, home: fixture.home, host: 'cursor' as const }; + const originalStateRoot = userDataStateRoot(destination, originalEnvironment, fixture.home); + const currentStateRoot = userDataStateRoot(destination, currentEnvironment, fixture.home); + const currentSentinel = join(currentStateRoot, 'unrelated.txt'); + try { + await installBundle({ ...options, environment: originalEnvironment }); + await mkdir(originalStateRoot, { recursive: true }); + await writeFile(join(originalStateRoot, 'state.sqlite'), 'original\n'); + const receiptPath = join(destination, installReceiptFile); + const receipt = JSON.parse(await readFile(receiptPath, 'utf8')) as Record; + const { + hostDirectories: _hostDirectories, + mode: _mode, + registrations: _registrations, + scope: _scope, + state: _state, + stateRoot: _stateRoot, + updatedAt: _updatedAt, + ...legacy + } = receipt; + await writeFile(receiptPath, JSON.stringify({ + ...legacy, + format: 'agent-bundle-install-receipt/1', + })); + await mkdir(currentStateRoot, { recursive: true }); + await writeFile(currentSentinel, 'unrelated\n'); + + const plan = await uninstallBundle({ + ...options, + confirmPurge: true, + environment: currentEnvironment, + plan: true, + purgeData: true, + }); + expect(plan.data).toMatchObject({ + outcome: 'kept', + paths: [], + retained: [{ path: currentStateRoot, reason: 'unproven' }], + }); + expect(plan.removed.directories).not.toContain(currentStateRoot); + + const kept = await uninstallBundle({ ...options, environment: currentEnvironment, keepData: true }); + expect(kept.data).toMatchObject({ + outcome: 'kept', + paths: [], + retained: [{ path: currentStateRoot, reason: 'unproven' }], + }); + const remnant = await readInstallReceipt(destination); + expect(remnant?.state).toBeUndefined(); + expect(remnant?.stateRoot).toBeUndefined(); + + const purged = await uninstallBundle({ + ...options, + confirmPurge: true, + environment: currentEnvironment, + purgeData: true, + }); + expect(purged.data).toMatchObject({ + outcome: 'kept', + paths: [], + retained: [{ path: currentStateRoot, reason: 'unproven' }], + }); + expect(await readFile(currentSentinel, 'utf8')).toBe('unrelated\n'); + expect(await readFile(join(originalStateRoot, 'state.sqlite'), 'utf8')).toBe('original\n'); + + // The #642 compatibility receipt remains authoritative only for the exact derived root it recorded. + await installBundle({ ...options, environment: originalEnvironment }); + const currentReceipt = JSON.parse(await readFile(receiptPath, 'utf8')) as Record; + const { state: _currentState, ...recordedLegacy } = currentReceipt; + await writeFile(receiptPath, JSON.stringify({ + ...recordedLegacy, + stateRoot: { root: originalStateRoot, source: 'derived' }, + })); + const recordedPlan = await uninstallBundle({ + ...options, + confirmPurge: true, + environment: currentEnvironment, + plan: true, + purgeData: true, + }); + expect(recordedPlan.data).toMatchObject({ + outcome: 'purged', + paths: [originalStateRoot], + }); + expect(recordedPlan.removed.directories).not.toContain(currentStateRoot); + await uninstallBundle({ + ...options, + confirmPurge: true, + environment: currentEnvironment, + purgeData: true, + }); + await expect(readFile(join(originalStateRoot, 'state.sqlite'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); + expect(await readFile(currentSentinel, 'utf8')).toBe('unrelated\n'); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + const gitAvailable = (): Promise => new Promise((resolvePromise) => { execFile('git', ['--version'], (error) => { resolvePromise(error === null); }); }); @@ -1640,6 +1745,84 @@ it('purges Claude durable state only when confirmed and reports the host-retaine } }); +it('never derives legacy host-receipt purge ownership from the current environment', async () => { + const fixture = await createFixture('claude'); + const hostRoot = join(fixture.cleanupRoot, 'claude-root'); + const installPath = join(hostRoot, 'plugins', 'cache', 'uninstall-fixture-marketplace', 'uninstall-fixture', '1.2.3'); + const receiptPath = join(hostRoot, 'agent-bundle', 'receipts', 'uninstall-fixture.uninstall-fixture-marketplace.user.json'); + const originalEnvironment = { + CLAUDE_CONFIG_DIR: hostRoot, + XDG_STATE_HOME: join(fixture.cleanupRoot, 'original-state-home'), + }; + const currentEnvironment = { + CLAUDE_CONFIG_DIR: hostRoot, + XDG_STATE_HOME: join(fixture.cleanupRoot, 'current-state-home'), + }; + const originalStateRoot = userDataStateRoot(installPath, originalEnvironment, fixture.home); + const currentStateRoot = userDataStateRoot(installPath, currentEnvironment, fixture.home); + const currentSentinel = join(currentStateRoot, 'unrelated.txt'); + let installed = false; + const { runner } = recordingRunner((call) => { + const verb = call.args.join(' '); + if (verb === 'plugin list --json') { + return claudeListing(installed + ? [{ enabled: true, id: 'uninstall-fixture@uninstall-fixture-marketplace', installPath, scope: 'user', version: '1.2.3' }] + : []); + } + if (verb === 'plugin marketplace list --json') return JSON.stringify([{ name: 'uninstall-fixture-marketplace' }]); + if (verb.startsWith('plugin install ')) installed = true; + if (verb.startsWith('plugin uninstall ')) installed = false; + return ''; + }); + const options = { + commandRunner: runner, + from: fixture.bundleRoot, + home: fixture.home, + host: 'claude' as const, + }; + try { + await installBundle({ ...options, environment: originalEnvironment }); + await cp(fixture.bundleRoot, installPath, { recursive: true }); + const receipt = JSON.parse(await readFile(receiptPath, 'utf8')) as Record; + const { state: _state, stateRoot: _stateRoot, ...legacy } = receipt; + await writeFile(receiptPath, JSON.stringify(legacy)); + await mkdir(originalStateRoot, { recursive: true }); + await writeFile(join(originalStateRoot, 'state.sqlite'), 'original\n'); + await mkdir(currentStateRoot, { recursive: true }); + await writeFile(currentSentinel, 'unrelated\n'); + + const plan = await uninstallBundle({ + ...options, + confirmPurge: true, + environment: currentEnvironment, + plan: true, + purgeData: true, + }); + expect(plan.data).toMatchObject({ + outcome: 'kept', + paths: [], + retained: [{ path: currentStateRoot, reason: 'unproven' }], + }); + expect(plan.removed.directories).not.toContain(currentStateRoot); + + const purged = await uninstallBundle({ + ...options, + confirmPurge: true, + environment: currentEnvironment, + purgeData: true, + }); + expect(purged.data).toMatchObject({ + outcome: 'kept', + paths: [], + retained: [{ path: currentStateRoot, reason: 'unproven' }], + }); + expect(await readFile(currentSentinel, 'utf8')).toBe('unrelated\n'); + expect(await readFile(join(originalStateRoot, 'state.sqlite'), 'utf8')).toBe('original\n'); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + it('reacquires Claude marketplace ownership after a keep-data remnant reinstall', async () => { const fixture = await createFixture('claude'); const hostRoot = join(fixture.cleanupRoot, 'claude-root'); diff --git a/website/docs/en/guide/distribution/installation.mdx b/website/docs/en/guide/distribution/installation.mdx index 0871026ce..a0bf7aea0 100644 --- a/website/docs/en/guide/distribution/installation.mdx +++ b/website/docs/en/guide/distribution/installation.mdx @@ -212,7 +212,10 @@ Agent Bundle cannot (Claude orphans its cached copy for a ~14-day grace period; cached tree and offers no keep-data option). A missing receipt (`AB7009`) or a content mismatch (`AB7007`) is refused unless `--force`; a directory that belongs to another plugin is refused regardless; a second run is a `not-installed` no-op. Receipts written before format 2 are read with -their lifecycle fields synthesized and diagnosed (`AB7329`), never rejected. +their lifecycle fields synthesized and diagnosed (`AB7329`), never rejected. When such a receipt +records neither a state location nor ownership, a root resolved from the current environment or home +is listed as unproven and retained; `--keep-data` cannot turn that observation into later purge +authority. The host-install proofs snapshot an isolated home before install and after uninstall: byte-identical for Cursor local and the portable Agent Plugins package, and zero Agent Bundle residue plus an diff --git a/website/docs/en/reference/cli.mdx b/website/docs/en/reference/cli.mdx index 6824df289..ccf48f28d 100644 --- a/website/docs/en/reference/cli.mdx +++ b/website/docs/en/reference/cli.mdx @@ -242,7 +242,10 @@ evidence authorizes deletion. The default `AGENT_BUNDLE_STATE_ROOT` is owned only when installation created the previously absent directory and wrote its install-identity marker. A pre-existing directory is never recursively removed merely because a server or the current uninstall environment names it. Multiple servers and roots are -recorded and judged independently. +recorded and judged independently. A supported older receipt with neither recorded state metadata +nor a recorded `stateRoot` cannot make the current environment or home a source of purge authority: +Doctor and uninstall report the observed root as `unrecorded` / unproven and retain it, including +after `--keep-data`. ## doctor @@ -276,8 +279,10 @@ before format 2 as migrated (`AB7329`). A Cursor directory holding only preserve from `uninstall --keep-data` is reported `missing` with an `AB7307` info, not corrupt or foreign. For every installed copy Doctor reports every per-server framework state root, its `native` or `derived` source, receipt ownership (`derived`, `marker`, `unowned`, or `unrecorded`), whether its -evidence is currently purgeable, the servers using it, whether it exists, and whether it is writable. A pre-#640 -`/state` is reported separately and flagged with `AB7332`. +evidence is currently purgeable, the servers using it, whether it exists, and whether it is writable. +For compatibility receipts with a recorded `stateRoot`, Doctor also lists that historical root when +the current environment resolves elsewhere. A pre-#640 `/state` is reported separately +and flagged with `AB7332`. ## validate diff --git a/website/docs/zh/guide/distribution/installation.mdx b/website/docs/zh/guide/distribution/installation.mdx index c88b9f416..104f6b12e 100644 --- a/website/docs/zh/guide/distribution/installation.mdx +++ b/website/docs/zh/guide/distribution/installation.mdx @@ -170,7 +170,9 @@ Claude、Codex 与 Cursor 市场模式的安装把回执放在 `<宿主根目录 包的 Cursor 副本在回执中记录的 `PLUGIN_DATA` 目录,除非传入 `--purge-data --confirm-purge` 否则保留,且结果如实说明宿主自行决定而 Agent Bundle 无法左右的部分(Claude 把缓存 副本标为 orphaned 并保留约 14 天;Codex 删除缓存树且没有 keep-data 选项)。缺少回执(`AB7009`)或内容不匹配 (`AB7007`)会被拒绝,除非 `--force`;属于另一个插件的目录无论如何都被拒绝;再次运行是 `not-installed` 空操作。 -格式 2 之前写入的回执会在补全生命周期字段后读取并给出诊断(`AB7329`),绝不被拒绝。 +格式 2 之前写入的回执会在补全生命周期字段后读取并给出诊断(`AB7329`),绝不被拒绝。如果这种回执既未记录 +状态位置也未记录归属,从当前环境或 home 解析出的根只会列为无法证明并保留;`--keep-data` 也不能把这次观察变成 +以后 purge 的权限。 宿主安装证明会在安装前与卸载后对隔离的 home 做快照:Cursor 本地与可移植 Agent Plugins 包做到字节一致;Claude 与 Codex 做到零 Agent Bundle 残留,并逐项列出宿主自有的记录文件。 diff --git a/website/docs/zh/reference/cli.mdx b/website/docs/zh/reference/cli.mdx index 618038619..073cee214 100644 --- a/website/docs/zh/reference/cli.mdx +++ b/website/docs/zh/reference/cli.mdx @@ -224,6 +224,8 @@ Claude 为 `retained-by-host`(缓存副本在 Claude 约 14 天的宽限期内 `$XDG_STATE_HOME/agent-bundle/-`)命名空间按构造归该安装独占。显式 `AGENT_BUNDLE_STATE_ROOT` 只有在安装时原本不存在、由安装器创建并写入安装身份标记时才归该安装所有。 预先存在的目录绝不会仅因服务器声明或卸载时的当前环境指向它而被递归删除。多个服务器与多个根会分别记录、分别判断。 +如果某份仍受支持的旧回执既没有状态元数据,也没有记录 `stateRoot`,当前环境或 home 就不能提供 purge 权限: +Doctor 与 uninstall 会把观察到的根报告为 `unrecorded` / 无法证明并予以保留,即使之前运行过 `--keep-data` 也一样。 ## doctor @@ -251,6 +253,7 @@ Claude 为 `retained-by-host`(缓存副本在 Claude 约 14 天的宽限期内 `AB7307` info 报告为 `missing`,而不是 corrupt 或 foreign。 对于每份已安装副本,Doctor 会报告每个服务器对应的框架状态根、其 `native` 或 `derived` 来源、回执归属 (`derived`、`marker`、`unowned` 或 `unrecorded`)、当前证据是否允许 purge、使用它的服务器、是否存在以及是否可写。 +对于记录了 `stateRoot` 的兼容回执,如果当前环境解析到别处,Doctor 还会单独列出这个历史根。 升级 #640 之前留下的 `/state` 会单独报告,并以 `AB7332` 标记。 ## validate