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/legacy-state-purge-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Make `uninstall --purge-data` retain state roots absent from legacy receipts and clarify `AB7332` ownership guidance (#689).
9 changes: 7 additions & 2 deletions docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | `<plugin root>/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 | `<plugin root>/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`)

Expand Down
3 changes: 3 additions & 0 deletions packages/agent-bundle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,9 @@ grace period; a purge also removes external framework state, web-data, `state/`,
and `plugins/data/<id>/`); 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
Expand Down
18 changes: 15 additions & 3 deletions packages/agent-bundle/src/install/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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',
Expand All @@ -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),
}));
}
Expand Down Expand Up @@ -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 <host> --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 <host> --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 <host> --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,
);
Expand Down
6 changes: 6 additions & 0 deletions packages/agent-bundle/src/install/state-root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 6 additions & 5 deletions packages/agent-bundle/src/install/surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>` 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,',
Expand Down Expand Up @@ -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' });",
" }",
' }',
Expand Down Expand Up @@ -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)),',
Expand Down
16 changes: 10 additions & 6 deletions packages/agent-bundle/src/install/uninstall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <host>` (#101): the receipt-owned reverse of
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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'));
Expand Down Expand Up @@ -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' });
}
}
}
Expand Down
120 changes: 114 additions & 6 deletions packages/agent-bundle/tests/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -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<string, unknown>;
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');
Expand Down
Loading
Loading