Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/owned-state-purge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'agent-bundle': patch
---

Make `uninstall --purge-data` remove only receipt-owned state roots and make `doctor` report per-server state ownership (#647)
9 changes: 6 additions & 3 deletions docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -1332,9 +1332,12 @@ diagnostic.

## Read-only Doctor legacy state (`AB7332`)

Doctor resolves each installed copy's effective framework state root from its
canonical code root and declared environment. It reports that root's source,
existence, and writability separately from the pre-#640 in-tree location.
Doctor resolves every installed MCP server's framework state root from its
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.

| Code | Severity | Trigger |
| --- | --- | --- |
Expand Down
10 changes: 8 additions & 2 deletions packages/agent-bundle/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -475,14 +475,20 @@ const humanDoctor = (result: DoctorReport): string => {
}
}
const reports = [
...host.inventory.findings.map((finding) => finding.durableState),
...host.inventory.findings.flatMap((finding) => finding.durableStates ?? (
finding.durableState === undefined ? [] : [finding.durableState]
)),
host.bundle?.durableState,
].filter((report): report is DoctorDurableStateReport => report !== undefined);
const uniqueReports = [...new Map(reports.map((report) => [report.directory, report])).values()];
for (const report of uniqueReports) {
out.push(
` state root: ${report.directory} (${report.exists ? 'exists' : 'missing'}, ` +
`${report.writable ? 'writable' : 'not writable'}, ${report.stateSource})\n`,
`${report.writable ? 'writable' : 'not writable'}, ${report.stateSource}); ` +
`ownership: ${report.ownership}${report.ownershipReason === undefined ? '' : ` (${report.ownershipReason})`}, ` +
`${report.purgeable ? 'purgeable' : 'retained'}${
report.servers.length === 0 ? '' : `, servers: ${report.servers.join(', ')}`
}\n`,
);
}
const legacyReports = host.inventory.findings
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-bundle/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -908,3 +908,5 @@ export const pluginRootEnvAnchor = 'AGENT_BUNDLE_PLUGIN_ROOT';

/** Explicit override of the framework state root; the runtime exports the same name as `PLUGIN_STATE_ROOT_ENV_ANCHOR`. */
export const pluginStateRootEnvAnchor = 'AGENT_BUNDLE_STATE_ROOT';

export const stateOwnershipMarkerFile = '.agent-bundle-state-owner.json';
1 change: 1 addition & 0 deletions packages/agent-bundle/src/dev/host-install-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,7 @@ export class DevHostInstallManager {
let installed = this.#installed.get(host);
if (installed === undefined) {
const result = await this.#installBundle({
environment: this.#environment,
from: prepared.root,
...(this.#home === undefined ? {} : { home: this.#home }),
host,
Expand Down
128 changes: 108 additions & 20 deletions packages/agent-bundle/src/install/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
installReceiptStoreDirectory,
isRemnantReceipt,
isRuntimeStateRemnant,
listStoredInstallReceipts,
readInstallReceipt,
readInstallReceiptFile,
treeInventory,
Expand All @@ -75,7 +76,10 @@ import {
} from './cursor-hooks-registration.ts';
import { cursorMarketplacePluginPath, cursorMarketplaceRoot } from './cursor-marketplace.ts';
import { bundleInventory, readBundleIdentity, type PluginIdentity } from './identity.ts';
import { resolveInstalledStateRoot } from './state-root.ts';
import {
inspectInstalledStateOwnership,
resolveInstalledStateRoots,
} from './state-root.ts';

export type DoctorHost = InstallHost;
export type DoctorHostProbeStatus = 'available' | 'failed' | 'unavailable';
Expand Down Expand Up @@ -135,6 +139,8 @@ export interface DoctorFinding {
/** Git commit of a staged Cursor marketplace repository. */
readonly commit?: string;
readonly durableState?: DoctorDurableStateReport;
/** Every current per-server state root, deduplicated by directory. */
readonly durableStates?: readonly DoctorDurableStateReport[];
/** Pre-#640 `<plugin root>/state`, reported separately from the effective state root. */
readonly legacyDurableState?: DoctorDurableStateReport;
/** The operator `.env` layer the installed pack's shells read at launch (#469); names and counts only, never values. */
Expand Down Expand Up @@ -252,6 +258,10 @@ export interface DoctorDurableStateReport {
readonly directory: string;
readonly exists: boolean;
readonly findings: readonly DoctorDurableStateStore[];
readonly ownership: 'derived' | 'legacy' | 'marker' | 'unowned' | 'unrecorded';
readonly ownershipReason?: string;
readonly purgeable: boolean;
readonly servers: readonly string[];
readonly stateSource: 'derived' | 'legacy' | 'native';
readonly status: 'known' | 'warnings';
readonly summary: {
Expand Down Expand Up @@ -465,13 +475,21 @@ const durableStateReport = (
stateSource: DoctorDurableStateReport['stateSource'],
findings: readonly DoctorDurableStateStore[],
diagnostics: readonly Diagnostic[],
ownership: DoctorDurableStateReport['ownership'] = stateSource === 'legacy' ? 'legacy' : 'unrecorded',
purgeable = false,
servers: readonly string[] = [],
ownershipReason?: string,
): DoctorDurableStateReport => {
const frozenDiagnostics = freezeDiagnostics(diagnostics);
return Object.freeze({
diagnostics: frozenDiagnostics,
directory,
exists,
findings: Object.freeze(findings.map((finding) => Object.freeze({ ...finding }))),
ownership,
...(ownershipReason === undefined ? {} : { ownershipReason }),
purgeable,
servers: Object.freeze([...servers]),
stateSource,
status: frozenDiagnostics.length === 0 ? 'known' : 'warnings',
summary: Object.freeze({
Expand Down Expand Up @@ -636,27 +654,75 @@ const inspectInstalledDurableState = async (
): Promise<{
readonly diagnostics: readonly Diagnostic[];
readonly effective: DoctorDurableStateReport;
readonly effectiveAll: readonly DoctorDurableStateReport[];
readonly legacy?: DoctorDurableStateReport;
}> => {
const resolved = receipt?.stateRoot ??
await resolveInstalledStateRoot(pluginRoot, host, environment, home);
const effective = await inspectDurableState(resolved.root, resolved.source, host);
const locations = await resolveInstalledStateRoots(pluginRoot, host, environment, home);
const grouped = new Map<string, { servers: string[]; source: 'declared' | 'derived' }>();
for (const location of locations) {
if (location.root === undefined) continue;
const current = grouped.get(location.root);
if (current === undefined) grouped.set(location.root, { servers: [location.server], source: location.source });
else current.servers.push(location.server);
}
const effectiveAll: DoctorDurableStateReport[] = [];
for (const [root, current] of grouped) {
const recorded = receipt?.state?.roots.find((candidate) => candidate.root === root);
const decision = recorded === undefined || receipt?.state === undefined
? undefined
: await inspectInstalledStateOwnership(receipt.state, recorded);
const ownership = recorded?.ownership.kind ?? 'unrecorded';
const inspected = await inspectDurableState(
root,
current.source === 'derived' ? 'derived' : 'native',
host,
);
effectiveAll.push(Object.freeze({
...inspected,
ownership,
...(recorded?.ownership.kind === 'unowned'
? { ownershipReason: recorded.ownership.reason }
: decision?.reason === undefined ? {} : { ownershipReason: decision.reason }),
purgeable: decision?.action === 'purge',
servers: Object.freeze(current.servers),
}));
}
const unresolvedServers = locations
.filter((location) => location.status === 'unproven')
.map((location) => location.server);
const effective = effectiveAll[0] ?? durableStateReport(
`<unresolved state root: ${unresolvedServers.join(', ')}>`,
false,
false,
'native',
[],
[],
'unrecorded',
false,
unresolvedServers,
'relative override has no provable execution directory',
);
const reportedAll = effectiveAll.length === 0 ? Object.freeze([effective]) : Object.freeze(effectiveAll);
const effectiveDiagnostics = reportedAll.flatMap((entry) => entry.diagnostics);
const legacyRoot = join(pluginRoot, 'state');
if (legacyRoot === resolved.root) {
return { diagnostics: effective.diagnostics, effective };
if (reportedAll.some((entry) => entry.directory === legacyRoot)) {
return { diagnostics: freezeDiagnostics(effectiveDiagnostics), effective, effectiveAll: reportedAll };
}
const legacy = await inspectDurableState(legacyRoot, 'legacy', host);
if (!legacy.exists) return { diagnostics: effective.diagnostics, effective };
if (!legacy.exists) {
return { diagnostics: freezeDiagnostics(effectiveDiagnostics), effective, effectiveAll: reportedAll };
}
const legacyDiagnostic = diagnostic(
'AB7332',
`Legacy durable state remains at ${JSON.stringify(legacyRoot)} while this install resolves framework state to ${JSON.stringify(resolved.root)}.`,
`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.',
'info',
host,
);
return {
diagnostics: freezeDiagnostics([...effective.diagnostics, ...legacy.diagnostics, legacyDiagnostic]),
diagnostics: freezeDiagnostics([...effectiveDiagnostics, ...legacy.diagnostics, legacyDiagnostic]),
effective,
effectiveAll: reportedAll,
legacy,
};
};
Expand Down Expand Up @@ -1022,6 +1088,7 @@ const cursorInventory = async (
diagnostics.push(await remnantDiagnostic(`Cursor plugin entry ${JSON.stringify(path)}`, path, remnantReceipt));
findings.push({
durableState: durableState.effective,
durableStates: durableState.effectiveAll,
...(durableState.legacy === undefined ? {} : { legacyDurableState: durableState.legacy }),
entry,
...(remnantReceipt === undefined ? {} : { name: remnantReceipt.plugin, receipt: receiptSummary(remnantReceipt), version: remnantReceipt.version }),
Expand Down Expand Up @@ -1073,26 +1140,27 @@ const cursorInventory = async (
}
diagnostics.push(...staticDiagnostics);
if (launch !== undefined) diagnostics.push(...launch.diagnostics);
const durableState = await inspectInstalledDurableState(path, 'cursor', environment, home);
diagnostics.push(...durableState.diagnostics);
const operatorEnv = await inspectOperatorEnv(path, 'cursor');
diagnostics.push(...operatorEnv.diagnostics);
const hooks = manifest.manifest === cursorManifestCandidates[0]
? await inspectCursorPluginHooks(path, home, { caseInsensitivePaths: platform === 'win32' })
: undefined;
if (hooks !== undefined) diagnostics.push(...hooks.diagnostics);
// The in-tree receipt is read-only evidence here: a pre-lifecycle receipt is diagnosed, never rewritten.
let receipt: InstallReceipt | undefined;
try {
receipt = await readInstallReceipt(path);
} catch {
receipt = undefined;
}
const durableState = await inspectInstalledDurableState(path, 'cursor', environment, home, receipt);
diagnostics.push(...durableState.diagnostics);
const operatorEnv = await inspectOperatorEnv(path, 'cursor');
diagnostics.push(...operatorEnv.diagnostics);
const hooks = manifest.manifest === cursorManifestCandidates[0]
? await inspectCursorPluginHooks(path, home, { caseInsensitivePaths: platform === 'win32' })
: undefined;
if (hooks !== undefined) diagnostics.push(...hooks.diagnostics);
if (receipt?.migratedFrom !== undefined) {
diagnostics.push(migratedReceiptDiagnostic('cursor', join(path, installReceiptFile), receipt));
}
findings.push({
durableState: durableState.effective,
durableStates: durableState.effectiveAll,
...(durableState.legacy === undefined ? {} : { legacyDurableState: durableState.legacy }),
entry,
...(hooks === undefined ? {} : { hooks: hooks.registration }),
Expand Down Expand Up @@ -1204,6 +1272,7 @@ const publicHostInventory = async (
}
const findings: DoctorFinding[] = [];
const diagnostics: Diagnostic[] = [];
const storedReceipts = await listStoredInstallReceipts(publicHostRoot(host, environment, home));
if (host === 'claude') {
if (!Array.isArray(document)) return unknown('not an array');
for (const row of document) {
Expand All @@ -1222,14 +1291,28 @@ const publicHostInventory = async (
// `enabled: false` is a copy the user switched off (`claude plugin disable`): installed, but no
// hooks, MCP servers, or skills reach a session until it is enabled again (#476).
const enabled = typeof row['enabled'] === 'boolean' ? row['enabled'] : undefined;
const durableState = await inspectInstalledDurableState(row['installPath'], host, environment, home);
const name = row['id'].slice(0, row['id'].indexOf('@') === -1 ? undefined : row['id'].indexOf('@'));
const receiptCandidates = storedReceipts.receipts.filter((stored) =>
stored.receipt.plugin === name &&
stored.receipt.scope === row['scope'] &&
stored.receipt.version === row['version']
);
const rowProjectRoot = typeof row['projectPath'] === 'string' ? resolve(row['projectPath']) : undefined;
const matchingReceipts = rowProjectRoot === undefined
? receiptCandidates
: receiptCandidates.filter((stored) =>
stored.receipt.projectRoot !== undefined &&
resolve(stored.receipt.projectRoot) === rowProjectRoot);
const receipt = matchingReceipts.length === 1 ? matchingReceipts[0]?.receipt : undefined;
const durableState = await inspectInstalledDurableState(row['installPath'], host, environment, home, receipt);
diagnostics.push(...durableState.diagnostics);
findings.push({
durableState: durableState.effective,
durableStates: durableState.effectiveAll,
...(enabled === undefined ? {} : { enabled }),
entry: `${row['id']} (${row['scope']})`,
...(errors.length === 0 ? {} : { errors }),
name: row['id'].slice(0, row['id'].indexOf('@') === -1 ? undefined : row['id'].indexOf('@')),
name,
path: row['installPath'],
...(durableState.legacy === undefined ? {} : { legacyDurableState: durableState.legacy }),
state: errors.length > 0 ? 'failed' : enabled === false ? 'disabled' : 'installed',
Expand All @@ -1247,10 +1330,15 @@ const publicHostInventory = async (
const name = separator === -1 ? row['pluginId'] : row['pluginId'].slice(0, separator);
const marketplace = separator === -1 ? '' : row['pluginId'].slice(separator + 1);
const path = join(publicHostCacheRoot(host, environment, home), marketplace, name, row['version']);
const durableState = await inspectInstalledDurableState(path, host, environment, home);
const receipt = storedReceipts.receipts.find((stored) =>
stored.receipt.plugin === name &&
stored.receipt.version === row['version']
)?.receipt;
const durableState = await inspectInstalledDurableState(path, host, environment, home, receipt);
diagnostics.push(...durableState.diagnostics);
findings.push({
durableState: durableState.effective,
durableStates: durableState.effectiveAll,
entry: row['pluginId'],
name,
path,
Expand Down
Loading
Loading