diff --git a/.changeset/owned-state-purge.md b/.changeset/owned-state-purge.md new file mode 100644 index 000000000..4fded014f --- /dev/null +++ b/.changeset/owned-state-purge.md @@ -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) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 8d8f05d77..01eb6f7b6 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -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 | | --- | --- | --- | diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts index d45970fed..e16678df9 100644 --- a/packages/agent-bundle/src/cli.ts +++ b/packages/agent-bundle/src/cli.ts @@ -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 diff --git a/packages/agent-bundle/src/core/types.ts b/packages/agent-bundle/src/core/types.ts index 828e395ed..e2843bdae 100644 --- a/packages/agent-bundle/src/core/types.ts +++ b/packages/agent-bundle/src/core/types.ts @@ -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'; diff --git a/packages/agent-bundle/src/dev/host-install-manager.ts b/packages/agent-bundle/src/dev/host-install-manager.ts index 226db7766..88a662469 100644 --- a/packages/agent-bundle/src/dev/host-install-manager.ts +++ b/packages/agent-bundle/src/dev/host-install-manager.ts @@ -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, diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index 489905330..d5a0cf836 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -51,6 +51,7 @@ import { installReceiptStoreDirectory, isRemnantReceipt, isRuntimeStateRemnant, + listStoredInstallReceipts, readInstallReceipt, readInstallReceiptFile, treeInventory, @@ -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'; @@ -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 `/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. */ @@ -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: { @@ -465,6 +475,10 @@ 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({ @@ -472,6 +486,10 @@ const durableStateReport = ( 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({ @@ -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(); + 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( + ``, + 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 --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, }; }; @@ -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 }), @@ -1073,14 +1140,6 @@ 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 { @@ -1088,11 +1147,20 @@ const cursorInventory = async ( } 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 }), @@ -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) { @@ -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', @@ -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, diff --git a/packages/agent-bundle/src/install/install.ts b/packages/agent-bundle/src/install/install.ts index b6babd50c..89153f660 100644 --- a/packages/agent-bundle/src/install/install.ts +++ b/packages/agent-bundle/src/install/install.ts @@ -28,6 +28,7 @@ import { installReceiptStorePath, isRemnantReceipt, isRuntimeStateRemnant, + readInstallReceipt, readInstallReceiptFile, replaceInstalledTree, stageArtifact, @@ -36,10 +37,12 @@ import { writeStoredInstallReceipt, type InstalledManifestIdentity, type InstalledTreeComparison, + type InstallReceipt, type InstallReceiptIdentity, type InstallRegistration, type TreeInventory, } from './receipt.ts'; +import { recordInstalledState } from './state-root.ts'; export type InstallHost = BundleIdentityHost; export type InstallScope = 'local' | 'project' | 'user'; @@ -482,6 +485,12 @@ const installPublicCli = async ( let previousContentHash: string | undefined; // Both hosts cache at `///` (pinned by the real-host proofs), so a // reported copy locates where the reinstalled version lands. + const predictedDestination = join( + publicHostCacheRoot(host, environment, home), + marketplace, + identity.plugin, + identity.version, + ); let destination: string | undefined; const entry = inventory.status === 'available' ? inventory.entries[0] : undefined; // The store receipt is the lifecycle record for this host-owned copy: written on every install and @@ -493,7 +502,7 @@ const installPublicCli = async ( // belongs to whoever configured it; when `plugin marketplace list --json` cannot say, the registration // is not claimed either (fail-closed: `uninstall` then retains it and says why). const receiptIdentity = async (): Promise => { - const ownsMarketplace = previousReceipt !== undefined + const ownsMarketplace = previousReceipt !== undefined && !isRemnantReceipt(previousReceipt) ? previousReceipt.registrations.some((registration) => registration.kind === `${host}-marketplace`) : await readPublicHostMarketplaceState(runner, identity, host, marketplace) === 'absent'; return { @@ -529,8 +538,34 @@ const installPublicCli = async ( if (installed !== undefined && sameVersion && installed.hash === artifact.hash) { // Byte-identical, so reinstalling cannot help: the host's refusal is the artifact's own defect. if (entry.errors !== undefined && entry.errors.length > 0) throw refusedInstallFailure(host, id, entry, 'existing'); - if (previousReceipt === undefined || previousReceipt.contentHash !== artifact.hash) { - await writeStoredInstallReceipt(receiptPath, createInstallReceipt({ ...(await receiptIdentity()), inventory: storeInventory })); + if ( + previousReceipt === undefined || + previousReceipt.contentHash !== artifact.hash || + previousReceipt.state === undefined || + isRemnantReceipt(previousReceipt) + ) { + const receiptIdentityValue = await receiptIdentity(); + const state = await recordInstalledState({ + environment, + home, + host, + mode: 'host-cli', + plugin: identity.plugin, + pluginRoot: entry.installPath, + previous: previousReceipt?.state, + ...(projectRoot === undefined ? {} : { projectRoot }), + scope, + }); + try { + await writeStoredInstallReceipt(receiptPath, createInstallReceipt({ + ...receiptIdentityValue, + inventory: storeInventory, + state: state.state, + })); + } catch (error) { + await state.rollback(); + throw error; + } } return { ...base, destination: entry.installPath, state: 'already-installed' }; } @@ -573,20 +608,35 @@ const installPublicCli = async ( // as `already-installed`, and a marketplace without one would be sampled as pre-existing and retained as // user-owned by every later `uninstall`. Plugin first, then the marketplace — the order the host verbs // themselves require. - const createdMarketplace = previousReceipt === undefined && + const createdMarketplace = (previousReceipt === undefined || isRemnantReceipt(previousReceipt)) && recorded.registrations.some((registration) => registration.kind === `${host}-marketplace`); let pluginInstalled = false; + let stateRollback: (() => Promise) | undefined; try { await runHostCommand(runner, identity, host, host === 'claude' ? ['plugin', 'install', id, '--scope', scope] : ['plugin', 'add', id]); pluginInstalled = true; + const state = await recordInstalledState({ + environment, + home, + host, + mode: 'host-cli', + plugin: identity.plugin, + pluginRoot: destination ?? predictedDestination, + previous: previousReceipt?.state, + ...(projectRoot === undefined ? {} : { projectRoot }), + scope, + }); + stateRollback = state.rollback; await writeStoredInstallReceipt(receiptPath, createInstallReceipt({ ...recorded, inventory: storeInventory, + state: state.state, updatedAt: new Date().toISOString(), })); } catch (error) { + if (stateRollback !== undefined) await stateRollback(); const rollbacks: (readonly string[])[] = [ ...(pluginInstalled ? [publicHostUninstallArguments(host, id, scope)] : []), ...(createdMarketplace ? [publicHostMarketplaceRemoveArguments(marketplace)] : []), @@ -781,6 +831,47 @@ const withStagedArtifact = ( return yield* applied; }); +const attachCursorStateOwnership = async ( + destination: string, + environment: Readonly, + home: string, + previousState?: InstallReceipt['state'], +): Promise => { + const receipt = await readInstallReceipt(destination); + if (receipt === undefined) throw new Error(`Installed receipt is missing at ${destination}.`); + const recorded = await recordInstalledState({ + environment, + home, + host: 'cursor', + mode: receipt.mode, + plugin: receipt.plugin, + pluginRoot: destination, + previous: receipt.state ?? previousState, + scope: receipt.scope, + }); + try { + await writeInstallReceipt(destination, createInstallReceipt({ + ...(receipt.cursorExpansion === undefined ? {} : { cursorExpansion: receipt.cursorExpansion }), + directories: receipt.directories, + host: receipt.host, + hostDirectories: receipt.hostDirectories, + installedAt: receipt.installedAt, + inventory: { files: receipt.files, hash: receipt.contentHash }, + mode: receipt.mode, + plugin: receipt.plugin, + registrations: receipt.registrations, + scope: receipt.scope, + state: recorded.state, + updatedAt: new Date().toISOString(), + version: receipt.version, + ...(receipt.webDataRoot === undefined ? {} : { webDataRoot: receipt.webDataRoot }), + })); + } catch (error) { + await recorded.rollback(); + throw error; + } +}; + /** * The local Cursor install as an Effect program: only the leaf I/O is lifted * (root resolution, inventories, `exists`, `mkdir`, staging, receipts), the @@ -807,6 +898,8 @@ const installCursor = Effect.fnUntraced(function*( ); const installRoot = join(cursorRoot, 'plugins', 'local'); const destination = join(installRoot, identity.plugin); + const environment = options.environment ?? process.env; + const home = options.home ?? homedir(); const base = { bundleRoot: identity.bundleRoot, destination, @@ -845,6 +938,7 @@ const installCursor = Effect.fnUntraced(function*( }), (staged) => rename(staged.root, destination), ); + yield* liftPromise(() => attachCursorStateOwnership(destination, environment, home)); return { ...base, contentHash: artifact.hash, state: 'installed' } as const; } if (resolve(identity.bundleRoot) === destination) { @@ -870,12 +964,14 @@ const installCursor = Effect.fnUntraced(function*( if (comparison.status === 'current') { if (comparison.ownership === 'legacy' && options.replace === true) { // Adoption created nothing: the legacy copy's directories are not the installer's to prune. - yield* liftPromise(() => writeInstallReceipt(destination, createInstallReceipt({ + const adoptedReceipt = createInstallReceipt({ ...receipt, directories: [], hostDirectories: [], inventory: artifact, - }))); + }); + yield* liftPromise(() => writeInstallReceipt(destination, adoptedReceipt)); + yield* liftPromise(() => attachCursorStateOwnership(destination, environment, home)); return { ...base, contentHash: artifact.hash, state: 'adopted' } as const; } // A receipt-managed identical copy whose receipt predates format/2 is upgraded in place: the @@ -891,6 +987,9 @@ const installCursor = Effect.fnUntraced(function*( updatedAt: new Date().toISOString(), }))); } + if (comparison.ownership === 'receipt' && comparison.receipt?.state === undefined) { + yield* liftPromise(() => attachCursorStateOwnership(destination, environment, home)); + } return { ...base, contentHash: artifact.hash, state: 'already-installed' } as const; } const replaceable = (comparison.status === 'stale' && comparison.ownership === 'receipt') || remnant @@ -911,6 +1010,12 @@ const installCursor = Effect.fnUntraced(function*( }), (staged) => replaceInstalledTree({ comparison, destination, receipt: replacement, staged }), ); + yield* liftPromise(() => attachCursorStateOwnership( + destination, + environment, + home, + comparison.receipt?.state, + )); // Filling a state-only shell is a fresh install of plugin content, not a replacement of any. if (remnant) return { ...base, contentHash: artifact.hash, state: 'installed' } as const; return { diff --git a/packages/agent-bundle/src/install/receipt.ts b/packages/agent-bundle/src/install/receipt.ts index 666023803..750867023 100644 --- a/packages/agent-bundle/src/install/receipt.ts +++ b/packages/agent-bundle/src/install/receipt.ts @@ -14,11 +14,12 @@ import { rmdir, writeFile, } from 'node:fs/promises'; -import { basename, dirname, join, resolve, sep } from 'node:path'; +import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path'; import { stableJson } from '../core/digest.ts'; import { isErrno } from '../core/errors.ts'; import { exists, installReceiptFile, isInstallReceiptEntry, isPreservedRuntimeRoot } from '../core/paths.ts'; +import { stateOwnershipMarkerFile } from '../core/types.ts'; import { artifactManifestName, type ArtifactManifest } from '../build/manifest.ts'; import { OPERATOR_ENV_FILE_NAMES } from '../launch-env.ts'; @@ -111,6 +112,33 @@ export interface InstallReceiptCursorExpansion { readonly pluginRoot: string; } +export type InstallReceiptStateUnownedReason = 'foreign-marker' | 'pre-existing' | 'unproven'; + +export interface InstallReceiptStateOwner { + readonly host: string; + readonly id: string; + readonly mode: InstallReceiptMode; + readonly plugin: string; + readonly projectRoot?: string; + readonly scope: InstallReceiptScope; +} + +export interface InstallReceiptStateRoot { + readonly canonicalRoot: string; + readonly ownership: + | { readonly kind: 'derived' } + | { readonly kind: 'marker'; readonly marker: string } + | { readonly kind: 'unowned'; readonly reason: InstallReceiptStateUnownedReason }; + readonly root: string; + readonly servers: readonly string[]; + readonly source: 'declared' | 'derived'; +} + +export interface InstallReceiptState { + readonly owner: InstallReceiptStateOwner; + readonly roots: readonly InstallReceiptStateRoot[]; +} + export interface InstallReceipt { readonly contentHash: string; readonly cursorExpansion?: InstallReceiptCursorExpansion; @@ -149,6 +177,8 @@ export interface InstallReceipt { /** Host registrations the installer performed, in the order it performed them. */ readonly registrations: readonly InstallRegistration[]; readonly scope: InstallReceiptScope; + /** Per-server runtime locations and the independent evidence authorizing deletion. */ + readonly state?: InstallReceiptState; /** Effective framework state root retained by a Cursor `--keep-data` uninstall. */ readonly stateRoot?: { readonly root: string; @@ -503,6 +533,78 @@ const isRegistrationKind = (value: unknown): value is InstallRegistrationKind => const optionalString = (value: unknown): value is string | undefined => value === undefined || typeof value === 'string'; +const readReceiptState = (value: unknown): InstallReceiptState | undefined => { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined; + const record = value as Record; + const ownerValue = record['owner']; + if (ownerValue === null || typeof ownerValue !== 'object' || Array.isArray(ownerValue)) return undefined; + const owner = ownerValue as Record; + if ( + typeof owner['host'] !== 'string' || + typeof owner['id'] !== 'string' || + owner['id'].length === 0 || + !isReceiptMode(owner['mode']) || + typeof owner['plugin'] !== 'string' || + !optionalString(owner['projectRoot']) || + !isReceiptScope(owner['scope']) || + !Array.isArray(record['roots']) + ) { + return undefined; + } + const roots: InstallReceiptStateRoot[] = []; + for (const valueRoot of record['roots']) { + if (valueRoot === null || typeof valueRoot !== 'object' || Array.isArray(valueRoot)) return undefined; + const root = valueRoot as Record; + const ownershipValue = root['ownership']; + if ( + typeof root['canonicalRoot'] !== 'string' || + !isAbsolute(root['canonicalRoot']) || + typeof root['root'] !== 'string' || + !isAbsolute(root['root']) || + !Array.isArray(root['servers']) || + !root['servers'].every((server) => typeof server === 'string') || + (root['source'] !== 'declared' && root['source'] !== 'derived') || + ownershipValue === null || + typeof ownershipValue !== 'object' || + Array.isArray(ownershipValue) + ) { + return undefined; + } + const ownershipRecord = ownershipValue as Record; + const ownership = ownershipRecord['kind'] === 'derived' + ? Object.freeze({ kind: 'derived' as const }) + : ownershipRecord['kind'] === 'marker' && + typeof ownershipRecord['marker'] === 'string' && + ownershipRecord['marker'] === join(root['root'], stateOwnershipMarkerFile) + ? Object.freeze({ kind: 'marker' as const, marker: ownershipRecord['marker'] }) + : ownershipRecord['kind'] === 'unowned' && + (ownershipRecord['reason'] === 'foreign-marker' || + ownershipRecord['reason'] === 'pre-existing' || + ownershipRecord['reason'] === 'unproven') + ? Object.freeze({ kind: 'unowned' as const, reason: ownershipRecord['reason'] }) + : undefined; + if (ownership === undefined) return undefined; + roots.push(Object.freeze({ + canonicalRoot: root['canonicalRoot'], + ownership, + root: root['root'], + servers: Object.freeze([...root['servers']]), + source: root['source'], + })); + } + return Object.freeze({ + owner: Object.freeze({ + host: owner['host'], + id: owner['id'], + mode: owner['mode'], + plugin: owner['plugin'], + ...(owner['projectRoot'] === undefined ? {} : { projectRoot: owner['projectRoot'] }), + scope: owner['scope'], + }), + roots: Object.freeze(roots), + }); +}; + const readRegistration = (value: unknown): InstallRegistration | undefined => { if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined; const record = value as Record; @@ -549,6 +651,8 @@ const receiptFromDocument = (value: unknown): InstallReceipt | undefined => { return undefined; } const cursorExpansion = readCursorExpansion(record['cursorExpansion']); + const state = readReceiptState(record['state']); + if (record['state'] !== undefined && state === undefined) return undefined; const stateRootRecord = record['stateRoot']; const stateRoot = stateRootRecord !== undefined && stateRootRecord !== null && @@ -601,6 +705,14 @@ const receiptFromDocument = (value: unknown): InstallReceipt | undefined => { if (registration === undefined) return undefined; registrations.push(registration); } + const ownedState = state !== undefined && + state.owner.host === record['host'] && + state.owner.mode === record['mode'] && + state.owner.plugin === record['plugin'] && + state.owner.scope === record['scope'] && + state.owner.projectRoot === record['projectRoot'] + ? state + : undefined; return Object.freeze({ ...base, hostDirectories: Object.freeze([...record['hostDirectories']]), @@ -608,6 +720,7 @@ const receiptFromDocument = (value: unknown): InstallReceipt | undefined => { ...(typeof record['projectRoot'] === 'string' ? { projectRoot: record['projectRoot'] } : {}), registrations: Object.freeze(registrations), scope: record['scope'], + ...(ownedState === undefined ? {} : { state: ownedState }), updatedAt: record['updatedAt'], }); }; @@ -644,6 +757,7 @@ export const createInstallReceipt = (options: InstallReceiptIdentity & { readonly cursorExpansion?: InstallReceiptCursorExpansion; readonly directories?: readonly string[]; readonly inventory: TreeInventory; + readonly state?: InstallReceiptState; readonly stateRoot?: InstallReceipt['stateRoot']; readonly webDataRoot?: string; }): InstallReceipt => { @@ -662,6 +776,18 @@ export const createInstallReceipt = (options: InstallReceiptIdentity & { ...(options.projectRoot === undefined ? {} : { projectRoot: options.projectRoot }), registrations: Object.freeze(options.registrations.map((registration) => Object.freeze({ ...registration }))), scope: options.scope, + ...(options.state === undefined + ? {} + : { + state: Object.freeze({ + owner: Object.freeze({ ...options.state.owner }), + roots: Object.freeze(options.state.roots.map((root) => Object.freeze({ + ...root, + ownership: Object.freeze({ ...root.ownership }), + servers: Object.freeze([...root.servers]), + }))), + }), + }), ...(options.stateRoot === undefined ? {} : { stateRoot: Object.freeze({ ...options.stateRoot }) }), updatedAt: options.updatedAt ?? installedAt, version: options.version, diff --git a/packages/agent-bundle/src/install/state-root.ts b/packages/agent-bundle/src/install/state-root.ts index ddd9899ae..515a15fe8 100644 --- a/packages/agent-bundle/src/install/state-root.ts +++ b/packages/agent-bundle/src/install/state-root.ts @@ -1,19 +1,33 @@ -import { createHash } from 'node:crypto'; -import { readFile, realpath } from 'node:fs/promises'; -import { basename, isAbsolute, join, resolve } from 'node:path'; +import { createHash, randomUUID } from 'node:crypto'; +import { lstat, mkdir, open, readFile, readdir, realpath, rm, rmdir } from 'node:fs/promises'; +import { basename, dirname, isAbsolute, join, resolve } from 'node:path'; import { readArtifactManifest } from '../build/manifest-file.ts'; import { isErrno } from '../core/errors.ts'; import { isRecord } from '../core/strict-json.ts'; -import { pluginStateRootEnvAnchor } from '../core/types.ts'; +import { pluginStateRootEnvAnchor, stateOwnershipMarkerFile } from '../core/types.ts'; import { webPluginDataRoot } from '../web-host/launch.ts'; import type { InstallHost } from './install.ts'; +import type { + InstallReceiptMode, + InstallReceiptScope, + InstallReceiptState, + InstallReceiptStateOwner, + InstallReceiptStateRoot, +} from './receipt.ts'; export interface InstalledStateRoot { readonly root: string; readonly source: 'derived' | 'native'; } +export interface InstalledStateLocation { + readonly root?: string; + readonly server: string; + readonly source: 'declared' | 'derived'; + readonly status: 'resolved' | 'unproven'; +} + const safePluginSegment = /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$/u; // The CLI cannot load the optional React runtime. Uninstall tests pin this spelling against @@ -38,29 +52,122 @@ const installedMcpDocument = async (pluginRoot: string, host: InstallHost): Prom return read.manifest.projections.find((projection) => projection.builtInHost === host)?.documents.mcp; }; -const declaredStateRoot = async (pluginRoot: string, host: InstallHost): Promise => { +const installedServers = async ( + pluginRoot: string, + host: InstallHost, +): Promise; readonly name: string }[]> => { const relativePath = await installedMcpDocument(pluginRoot, host); - if (relativePath === undefined) return undefined; + if (relativePath === undefined) return []; let document: unknown; try { document = JSON.parse(await readFile(join(pluginRoot, relativePath), 'utf8')) as unknown; } catch (error) { - if (isErrno(error, 'ENOENT') || error instanceof SyntaxError) return undefined; + if (isErrno(error, 'ENOENT') || error instanceof SyntaxError) return []; throw error; } - if (!isRecord(document) || !isRecord(document['mcpServers'])) return undefined; - for (const server of Object.values(document['mcpServers'])) { - if (!isRecord(server) || !isRecord(server['env'])) continue; - const declared = server['env'][pluginStateRootEnvAnchor]; - if (typeof declared !== 'string' || declared.trim() === '') continue; - const expanded = declared - .replaceAll('${CLAUDE_PLUGIN_ROOT}', pluginRoot) - .replaceAll('${CURSOR_PLUGIN_ROOT}', pluginRoot) - .replaceAll('${PLUGIN_ROOT}', pluginRoot); - if (/\$\{[^}]*\}/u.test(expanded)) continue; - return isAbsolute(expanded) ? resolve(expanded) : resolve(pluginRoot, expanded); + if (!isRecord(document) || !isRecord(document['mcpServers'])) return []; + return Object.entries(document['mcpServers']) + .filter((entry): entry is [string, Record] => isRecord(entry[1])) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([name, server]) => ({ + ...(typeof server['cwd'] === 'string' ? { cwd: server['cwd'] } : {}), + environment: isRecord(server['env']) + ? Object.fromEntries(Object.entries(server['env']).filter((entry): entry is [string, string] => + typeof entry[1] === 'string')) + : {}, + name, + })); +}; + +const expandPluginRoot = (value: string, pluginRoot: string): string => + value + .replaceAll('${CLAUDE_PLUGIN_ROOT}', pluginRoot) + .replaceAll('${CURSOR_PLUGIN_ROOT}', pluginRoot) + .replaceAll('${PLUGIN_ROOT}', pluginRoot); + +export const resolveInstalledStateRoots = async ( + pluginRoot: string, + host: InstallHost, + environment: Readonly, + home: string, +): Promise => { + const canonicalRoot = await realpath(pluginRoot).catch((error: unknown) => { + if (isErrno(error, 'ENOENT')) return resolve(pluginRoot); + throw error; + }); + const servers = await installedServers(canonicalRoot, host); + if (servers.length === 0) { + const inherited = environment[pluginStateRootEnvAnchor]; + if (inherited !== undefined && inherited.trim() !== '') { + const expanded = expandPluginRoot(inherited, canonicalRoot); + if (!/\$\{[^}]*\}/u.test(expanded) && isAbsolute(expanded)) { + return Object.freeze([Object.freeze({ + root: resolve(expanded), + server: 'default', + source: 'declared' as const, + status: 'resolved' as const, + })]); + } + if (/\$\{[^}]*\}/u.test(expanded)) { + return Object.freeze([Object.freeze({ + root: installedUserDataStateRoot(canonicalRoot, environment, home), + server: 'default', + source: 'derived' as const, + status: 'resolved' as const, + })]); + } + return Object.freeze([Object.freeze({ server: 'default', source: 'declared' as const, status: 'unproven' as const })]); + } + return Object.freeze([Object.freeze({ + root: installedUserDataStateRoot(canonicalRoot, environment, home), + server: 'default', + source: 'derived' as const, + status: 'resolved' as const, + })]); } - return undefined; + return Object.freeze(servers.map((server) => { + const declared = server.environment[pluginStateRootEnvAnchor] ?? + environment[pluginStateRootEnvAnchor]; + if (declared === undefined || declared.trim() === '') { + return Object.freeze({ + root: installedUserDataStateRoot(canonicalRoot, environment, home), + server: server.name, + source: 'derived' as const, + status: 'resolved' as const, + }); + } + const expanded = expandPluginRoot(declared, canonicalRoot); + if (/\$\{[^}]*\}/u.test(expanded)) { + return Object.freeze({ + root: installedUserDataStateRoot(canonicalRoot, environment, home), + server: server.name, + source: 'derived' as const, + status: 'resolved' as const, + }); + } + if (isAbsolute(expanded)) { + return Object.freeze({ + root: resolve(expanded), + server: server.name, + source: 'declared' as const, + status: 'resolved' as const, + }); + } + if (server.cwd === undefined) { + return Object.freeze({ server: server.name, source: 'declared' as const, status: 'unproven' as const }); + } + const expandedCwd = expandPluginRoot(server.cwd, canonicalRoot); + if (/\$\{[^}]*\}/u.test(expandedCwd)) { + return Object.freeze({ server: server.name, source: 'declared' as const, status: 'unproven' as const }); + } + const cwd = isAbsolute(expandedCwd) ? resolve(expandedCwd) : resolve(canonicalRoot, expandedCwd); + return Object.freeze({ + root: resolve(cwd, expanded), + server: server.name, + source: 'declared' as const, + status: 'resolved' as const, + }); + })); }; export const resolveInstalledStateRoot = async ( @@ -69,19 +176,258 @@ export const resolveInstalledStateRoot = async ( environment: Readonly, home: string, ): Promise => { - const canonicalRoot = await realpath(pluginRoot).catch((error: unknown) => { - if (isErrno(error, 'ENOENT')) return resolve(pluginRoot); + const locations = await resolveInstalledStateRoots(pluginRoot, host, environment, home); + const first = locations.find((location) => location.root !== undefined); + if (first !== undefined && first.root !== undefined) { + return Object.freeze({ root: first.root, source: first.source === 'derived' ? 'derived' : 'native' }); + } + const canonicalRoot = await realpath(pluginRoot).catch(() => resolve(pluginRoot)); + return Object.freeze({ + root: installedUserDataStateRoot(canonicalRoot, environment, home), + source: 'derived', + }); +}; + +const canonicalPath = async (path: string): Promise => { + try { + return await realpath(path); + } catch (error) { + if (!isErrno(error, 'ENOENT')) throw error; + const parent = dirname(path); + if (parent === path) return resolve(path); + return join(await canonicalPath(parent), basename(path)); + } +}; + +const markerDocument = (owner: InstallReceiptStateOwner): string => + `${JSON.stringify({ format: 1, owner }, null, 2)}\n`; + +const markerMatches = async (marker: string, owner: InstallReceiptStateOwner): Promise => { + try { + const document = JSON.parse(await readFile(marker, 'utf8')) as unknown; + return isRecord(document) && + document['format'] === 1 && + isRecord(document['owner']) && + document['owner']['id'] === owner.id && + document['owner']['host'] === owner.host && + document['owner']['mode'] === owner.mode && + document['owner']['plugin'] === owner.plugin && + document['owner']['scope'] === owner.scope && + document['owner']['projectRoot'] === owner.projectRoot; + } catch (error) { + if (isErrno(error, 'ENOENT') || error instanceof SyntaxError) return false; + throw error; + } +}; + +export interface RecordInstalledStateOptions { + readonly environment: Readonly; + readonly home: string; + readonly host: InstallHost; + readonly mode: InstallReceiptMode; + readonly plugin: string; + readonly pluginRoot: string; + readonly previous?: InstallReceiptState; + readonly projectRoot?: string; + readonly scope: InstallReceiptScope; +} + +export interface RecordedInstalledState { + readonly rollback: () => Promise; + readonly state: InstallReceiptState; +} + +export interface InstalledStateOwnershipDecision { + readonly action: 'absent' | 'empty' | 'purge' | 'retain'; + readonly marker?: string; + readonly path: string; + readonly reason?: string; +} + +export const inspectInstalledStateOwnership = async ( + state: InstallReceiptState, + root: InstallReceiptStateRoot, +): Promise => { + let metadata; + try { + metadata = await lstat(root.root); + } catch (error) { + if (isErrno(error, 'ENOENT')) return Object.freeze({ action: 'absent', path: root.root }); throw error; + } + if (root.ownership.kind === 'unowned') { + return Object.freeze({ action: 'retain', path: root.root, reason: root.ownership.reason }); + } + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + return Object.freeze({ action: 'retain', path: root.root, reason: 'unsupported-entry' }); + } + if (await realpath(root.root) !== root.canonicalRoot) { + return Object.freeze({ action: 'retain', path: root.root, reason: 'canonical-path-changed' }); + } + if ( + root.ownership.kind === 'marker' && + ( + root.ownership.marker !== join(root.root, stateOwnershipMarkerFile) || + !(await markerMatches(root.ownership.marker, state.owner)) + ) + ) { + return Object.freeze({ action: 'retain', path: root.root, reason: 'marker-mismatch' }); + } + const entries = await readdir(root.root); + if (entries.length === 0) return Object.freeze({ action: 'empty', path: root.root }); + if ( + root.ownership.kind === 'marker' && + entries.length === 1 && + entries[0] === stateOwnershipMarkerFile + ) { + return Object.freeze({ action: 'empty', marker: root.ownership.marker, path: root.root }); + } + return Object.freeze({ action: 'purge', path: root.root }); +}; + +export const recordInstalledState = async ( + options: RecordInstalledStateOptions, +): Promise => { + const owner = Object.freeze({ + host: options.host, + id: options.previous?.owner.id ?? randomUUID(), + mode: options.mode, + plugin: options.plugin, + ...(options.projectRoot === undefined ? {} : { projectRoot: options.projectRoot }), + scope: options.scope, + }); + const locations = await resolveInstalledStateRoots( + options.pluginRoot, + options.host, + options.environment, + options.home, + ); + const roots = new Map(); + for (const location of locations) { + if (location.root === undefined) continue; + const previous = roots.get(location.root); + if (previous === undefined) roots.set(location.root, { location, servers: [location.server] }); + else previous.servers.push(location.server); + } + const created: string[] = []; + const recorded: InstallReceiptStateRoot[] = []; + const rollback = async (): Promise => { + for (const root of [...created].reverse()) { + await rm(join(root, stateOwnershipMarkerFile), { force: true }); + await rmdir(root).catch((error: unknown) => { + if (!isErrno(error, 'ENOENT') && !isErrno(error, 'ENOTEMPTY')) throw error; + }); + } + }; + try { + for (const { location, servers } of roots.values()) { + const root = location.root as string; + if (location.source === 'derived') { + recorded.push(Object.freeze({ + canonicalRoot: await canonicalPath(root), + ownership: Object.freeze({ kind: 'derived' as const }), + root, + servers: Object.freeze(servers), + source: 'derived', + })); + continue; + } + try { + const marker = join(root, stateOwnershipMarkerFile); + let existed = true; + try { + await lstat(root); + } catch (error) { + if (!isErrno(error, 'ENOENT')) throw error; + existed = false; + } + let ownership: InstallReceiptStateRoot['ownership']; + if (!existed) { + await mkdir(dirname(root), { recursive: true }); + try { + await mkdir(root); + created.push(root); + const handle = await open(marker, 'wx'); + try { + await handle.writeFile(markerDocument(owner), 'utf8'); + } finally { + await handle.close(); + } + ownership = Object.freeze({ kind: 'marker' as const, marker }); + } catch (error) { + if (!isErrno(error, 'EEXIST')) { + if (created.at(-1) === root) { + created.pop(); + await rmdir(root).catch((rollbackError: unknown) => { + if (!isErrno(rollbackError, 'ENOENT') && !isErrno(rollbackError, 'ENOTEMPTY')) { + throw rollbackError; + } + }); + } + throw error; + } + if (created.at(-1) === root) created.pop(); + ownership = await markerMatches(marker, owner) + ? Object.freeze({ kind: 'marker' as const, marker }) + : Object.freeze({ kind: 'unowned' as const, reason: 'foreign-marker' as const }); + } + } else if (await markerMatches(marker, owner)) { + ownership = Object.freeze({ kind: 'marker' as const, marker }); + } else { + let markerExists = true; + try { + await lstat(marker); + } catch (error) { + if (!isErrno(error, 'ENOENT')) throw error; + markerExists = false; + } + ownership = Object.freeze({ + kind: 'unowned' as const, + reason: markerExists ? 'foreign-marker' as const : 'pre-existing' as const, + }); + } + recorded.push(Object.freeze({ + canonicalRoot: await canonicalPath(root), + ownership, + root, + servers: Object.freeze(servers), + source: 'declared', + })); + } catch (error) { + if ( + !isErrno(error, 'EACCES') && + !isErrno(error, 'ENOTDIR') && + !isErrno(error, 'EPERM') && + !isErrno(error, 'EROFS') + ) { + throw error; + } + if (created.at(-1) === root) { + created.pop(); + await rm(join(root, stateOwnershipMarkerFile), { force: true }); + await rmdir(root).catch((rollbackError: unknown) => { + if (!isErrno(rollbackError, 'ENOENT') && !isErrno(rollbackError, 'ENOTEMPTY')) { + throw rollbackError; + } + }); + } + recorded.push(Object.freeze({ + canonicalRoot: resolve(root), + ownership: Object.freeze({ kind: 'unowned' as const, reason: 'unproven' as const }), + root, + servers: Object.freeze(servers), + source: 'declared', + })); + } + } + } catch (error) { + await rollback(); + throw error; + } + return Object.freeze({ + rollback, + state: Object.freeze({ owner, roots: Object.freeze(recorded) }), }); - const fromManifest = await declaredStateRoot(canonicalRoot, host); - const inherited = environment[pluginStateRootEnvAnchor] ?? ''; - const expandedInherited = inherited.trim() === '' || /\$\{[^}]*\}/u.test(inherited) - ? undefined - : isAbsolute(inherited) ? resolve(inherited) : resolve(canonicalRoot, inherited); - const declared = fromManifest ?? expandedInherited; - return Object.freeze(declared === undefined - ? { root: installedUserDataStateRoot(canonicalRoot, environment, home), source: 'derived' as const } - : { root: declared, source: 'native' as const }); }; export const installedWebDataRoot = (pluginRoot: string, home: string): string => diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index fa7eb5d2a..678ee8ccd 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -1,4 +1,4 @@ -import type { NormalizedPlugin } from '../core/types.ts'; +import { stateOwnershipMarkerFile, type NormalizedPlugin } from '../core/types.ts'; import { preservedRuntimeEntries } from '../core/paths.ts'; import { type BuiltInHost, builtInHostNames } from '../adapters/composite-layout.ts'; import { sourceInputs, type TargetArtifactWrite } from '../adapters/types.ts'; @@ -456,11 +456,47 @@ const cursorUninstallerSource = (): readonly string[] => [ ' }', ' if (await exists(join(destination, receiptFile))) files.push(join(destination, receiptFile));', " const [resolvedStateDirectory, stateDirectory, resolvedWebDataDirectory, resolvedStateSource] = await runtimeStateRoots();", - ' const effectiveStateDirectory = receipt?.stateRoot?.root ?? resolvedStateDirectory;', - ' const stateSource = receipt?.stateRoot?.source ?? resolvedStateSource;', + ' const retainedState = [];', + ' const ownedStatePaths = [];', + ' const emptyOwnedStateFiles = [];', + ' const emptyOwnedStateRoots = [];', + ' if (receipt?.state !== undefined) {', + ' for (const root of receipt.state.roots) {', + ' let metadata;', + " try { metadata = await lstat(root.root); } catch (error) { if (error?.code === 'ENOENT') continue; throw error; }", + " if (root.ownership.kind === 'unowned') { retainedState.push({ path: root.root, reason: root.ownership.reason }); continue; }", + " if (!metadata.isDirectory() || metadata.isSymbolicLink()) { retainedState.push({ path: root.root, reason: 'unsupported-entry' }); continue; }", + " if (await realpath(root.root) !== root.canonicalRoot) { retainedState.push({ path: root.root, reason: 'canonical-path-changed' }); continue; }", + " if (root.ownership.kind === 'marker') {", + ' let marker;', + " try { marker = JSON.parse(await readFile(root.ownership.marker, 'utf8')); } catch { marker = undefined; }", + ' const owner = marker?.owner;', + ' const expected = receipt.state.owner;', + ' if (root.ownership.marker !== join(root.root, stateMarkerFile) || marker?.format !== 1 || owner?.id !== expected.id ||', + ' owner?.host !== expected.host || owner?.mode !== expected.mode || owner?.plugin !== expected.plugin ||', + " owner?.scope !== expected.scope || owner?.projectRoot !== expected.projectRoot) { retainedState.push({ path: root.root, reason: 'marker-mismatch' }); continue; }", + ' }', + ' const entries = await readdir(root.root);', + ' if (entries.length === 0 || (root.ownership.kind === \'marker\' && entries.length === 1 && entries[0] === stateMarkerFile)) {', + ' emptyOwnedStateRoots.push(root.root);', + " if (root.ownership.kind === 'marker') emptyOwnedStateFiles.push(root.ownership.marker);", + ' continue;', + ' }', + ' ownedStatePaths.push(root.root);', + ' }', + ' } else {', + ' const fallbackStateDirectory = receipt?.stateRoot?.root ?? resolvedStateDirectory;', + " const fallbackStateSource = receipt?.stateRoot?.source ?? resolvedStateSource;", + ' 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);", + " else retainedState.push({ path: fallbackStateDirectory, reason: 'unproven' });", + " }", + ' }', ' const webDataDirectory = receipt?.webDataRoot ?? resolvedWebDataDirectory;', - ' const externalDataPaths = [];', - ' for (const path of [effectiveStateDirectory, webDataDirectory]) {', + ' const externalDataPaths = [...ownedStatePaths];', + ' for (const path of [webDataDirectory]) {', ' if (path === stateDirectory) continue;', ' let metadata;', " try { metadata = await lstat(path); } catch (error) { if (error?.code === 'ENOENT') continue; throw error; }", @@ -473,7 +509,7 @@ const cursorUninstallerSource = (): readonly string[] => [ ' // 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 = [...externalDataPaths, ...(stateMetadata === undefined || emptyState !== undefined ? [] : [stateDirectory])];', - " const dataKinds = [...externalDataPaths.map((path) => path === effectiveStateDirectory ? `framework state root ${path}` : `web-data directory ${path}`), ...(stateMetadata === undefined || emptyState !== undefined ? [] : ['legacy state/ (state kernel, notices journal)'])];", + " const dataKinds = [...externalDataPaths.map((path) => ownedStatePaths.includes(path) ? `owned framework state root ${path}` : `web-data directory ${path}`), ...(stateMetadata === undefined || emptyState !== undefined ? [] : ['legacy 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.', @@ -498,15 +534,16 @@ const cursorUninstallerSource = (): readonly string[] => [ ' else { dataPaths.push(pluginData); dataKinds.push(`the PLUGIN_DATA directory ${pluginData}`); }', ' }', ' }', - " const dataOutcome = dataPaths.length === 0 ? 'absent' : purgeData ? 'purged' : 'kept';", - ' const dataDetail = dataPaths.length === 0', + " const retainedStateNote = retainedState.length === 0 ? '' : ` Retained ${retainedState.map((entry) => `${entry.path} (${entry.reason})`).join(', ')} because the receipt does not prove exclusive ownership.`;", + " const dataOutcome = dataPaths.length === 0 ? retainedState.length === 0 ? 'absent' : 'kept' : purgeData ? 'purged' : 'kept';", + ' const dataDetail = dataPaths.length === 0 && retainedState.length === 0', " ? `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}`;", + " ? `${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.', - ' const keepRoot = !purgeData && dataPaths.some((path) => path !== stateDirectory);', + ' const keepRoot = !purgeData && [...dataPaths, ...retainedState.map((entry) => entry.path)].some((path) => path !== stateDirectory);', ' const directories = [', ' ...ownedDirectories.map((directory) => join(destination, directory)),', ' ...(keepRoot ? [] : [destination]),', @@ -514,7 +551,9 @@ const cursorUninstallerSource = (): readonly string[] => [ ' ...(emptyPluginData === undefined ? [] : [emptyPluginData]),', ' ...(emptyState === undefined ? [] : [emptyState]),', " ...(pluginDataRecorded ? [join(cursorRoot, 'agent-bundle', 'plugin-data'), join(cursorRoot, 'agent-bundle')] : []),", + ' ...emptyOwnedStateRoots,', ' ].sort((left, right) => right.length - left.length || left.localeCompare(right));', + ' files.push(...emptyOwnedStateFiles);', ' const ownedSet = new Set(owned);', ' const ownedDirectorySet = new Set(ownedDirectories);', ' const remnantOnly = receipt !== undefined && receipt.files.length === 0 && receipt.registrations.length === 0;', @@ -522,7 +561,7 @@ const cursorUninstallerSource = (): readonly string[] => [ ' // 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;', + ' const remnantGuards = dataPaths.length > 0 || retainedState.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.', @@ -552,6 +591,7 @@ const cursorUninstallerSource = (): readonly string[] => [ " printPaths('Would remove directory', [...purgedDirectories, ...prunable]);", " console.log(`Data (${purgeData ? 'purge' : 'keep'}): ${dataOutcome} — ${dataDetail}`);", ' for (const path of dataPaths) console.log(` ${path}`);', + ' for (const entry of retainedState) console.log(` retained ${entry.path}: ${entry.reason}`);', ' const retained = await listRetained(destination, ownedSet, ownedDirectorySet);', " if (retained.length > 0) printPaths(`Retained unowned under ${destination}:`, retained);", ' if (!prunable.includes(destination)) console.log(`Remnant receipt (would be written): ${join(destination, receiptFile)} — owns no files; keeps the created host directories receipt-owned for a later purge.`);', @@ -575,7 +615,8 @@ const cursorUninstallerSource = (): readonly string[] => [ ' // A kept PLUGIN_DATA directory stays receipt-owned through the remnant\'s expansion record.', ' ...(keepRoot && receipt?.cursorExpansion !== undefined ? { cursorExpansion: receipt.cursorExpansion } : {}),', ' directories: [], hostDirectories, installedAt: receipt?.installedAt, registrations: [],', - ' ...(keepRoot ? { stateRoot: { root: effectiveStateDirectory, source: stateSource }, webDataRoot: webDataDirectory } : {}),', + ' ...(receipt?.state === undefined ? {} : { state: receipt.state }),', + ' ...(keepRoot ? { webDataRoot: webDataDirectory } : {}),', ' }));', ' console.log(`Remnant receipt: ${join(destination, receiptFile)} — owns no files; keeps the created host directories receipt-owned for a later purge.`);', ' }', @@ -911,6 +952,18 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' registrationKinds.includes(value.kind) &&', " (value.commit === undefined || typeof value.commit === 'string') && (value.id === undefined || typeof value.id === 'string') &&", " (value.name === undefined || typeof value.name === 'string') && (value.scope === undefined || isScope(value.scope));", + "const isStateOwnership = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) && (", + " value.kind === 'derived' ||", + " (value.kind === 'marker' && typeof value.marker === 'string' && isAbsolute(value.marker)) ||", + " (value.kind === 'unowned' && ['foreign-marker', 'pre-existing', 'unproven'].includes(value.reason)));", + "const isReceiptState = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) &&", + " value.owner !== null && typeof value.owner === 'object' && !Array.isArray(value.owner) &&", + " typeof value.owner.id === 'string' && value.owner.id.length > 0 && typeof value.owner.host === 'string' &&", + " ['host-cli', 'local', 'marketplace'].includes(value.owner.mode) && typeof value.owner.plugin === 'string' && isScope(value.owner.scope) &&", + " Array.isArray(value.roots) && value.roots.every((root) => root !== null && typeof root === 'object' && !Array.isArray(root) &&", + " typeof root.root === 'string' && isAbsolute(root.root) && typeof root.canonicalRoot === 'string' && isAbsolute(root.canonicalRoot) &&", + " ['declared', 'derived'].includes(root.source) && Array.isArray(root.servers) && root.servers.every((server) => typeof server === 'string') &&", + ' isStateOwnership(root.ownership));', '// Same shape check as the core reader: a receipt missing any field reads as absent. A format/1 receipt (#420)', '// is read with its lifecycle fields synthesized (local mode, user scope, one cursor-local-plugin registration,', '// no host directories) and `migratedFrom` set; the next replacement rewrites it as the current format.', @@ -926,6 +979,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { " typeof value.host !== 'string' || typeof value.contentHash !== 'string' || typeof value.installedAt !== 'string' ||", ' !Array.isArray(value.files) || !value.files.every(safeRelative) ||', ' !Array.isArray(value.directories) || !value.directories.every(safeRelative) ||', + ' (value.state !== undefined && !isReceiptState(value.state)) ||', " (value.stateRoot !== undefined && (value.stateRoot === null || typeof value.stateRoot !== 'object' || Array.isArray(value.stateRoot) || typeof value.stateRoot.root !== 'string' || !['derived', 'native'].includes(value.stateRoot.source))) ||", " (value.webDataRoot !== undefined && typeof value.webDataRoot !== 'string')) return undefined;", ' if (value.format === legacyReceiptFormat) {', @@ -935,6 +989,9 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { " if (!['host-cli', 'local', 'marketplace'].includes(value.mode) || !isScope(value.scope) || typeof value.updatedAt !== 'string' ||", ' !Array.isArray(value.hostDirectories) || !value.hostDirectories.every(safeRelative) ||', ' !Array.isArray(value.registrations) || !value.registrations.every(isRegistration)) return undefined;', + ' if (value.state !== undefined && (value.state.owner.host !== value.host || value.state.owner.mode !== value.mode ||', + ' value.state.owner.plugin !== value.plugin || value.state.owner.scope !== value.scope ||', + ' value.state.owner.projectRoot !== value.projectRoot)) { value = { ...value }; delete value.state; }', ' return value;', '};', 'const readReceipt = (root) => readReceiptFile(join(root, receiptFile));', @@ -1053,6 +1110,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' plugin: pluginName,', " registrations: options.registrations ?? [{ kind: 'cursor-local-plugin' }],", " scope: 'user',", + ' ...(options.state === undefined ? {} : { state: options.state }),', ' ...(options.stateRoot === undefined ? {} : { stateRoot: options.stateRoot }),', ' updatedAt: now,', ' version: pluginVersion,', @@ -1060,6 +1118,130 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { " }, null, 2) + '\\n';", '};', '', + `const stateMarkerFile = ${JSON.stringify(stateOwnershipMarkerFile)};`, + "const expandStatePath = (value, root) => value.replaceAll('${CURSOR_PLUGIN_ROOT}', root).replaceAll('${PLUGIN_ROOT}', root);", + 'const canonicalPath = async (path) => {', + ' try { return await realpath(path); }', + " catch (error) { if (error?.code !== 'ENOENT') throw error; const parent = dirname(path); return parent === path ? resolve(path) : join(await canonicalPath(parent), basename(path)); }", + '};', + 'const stateLocations = async () => {', + ' const canonical = await realpath(destination);', + ' let servers = [];', + " for (const manifest of ['.cursor-plugin/mcp.json', 'mcp.json']) {", + ' let document;', + " try { document = JSON.parse(await readFile(join(canonical, manifest), 'utf8')); }", + " catch (error) { if (error?.code === 'ENOENT' || error instanceof SyntaxError) continue; throw error; }", + " if (document?.mcpServers !== null && typeof document?.mcpServers === 'object' && !Array.isArray(document.mcpServers)) {", + " servers = Object.entries(document.mcpServers).filter(([, server]) => server !== null && typeof server === 'object' && !Array.isArray(server)).sort(([left], [right]) => left.localeCompare(right));", + ' break;', + ' }', + ' }', + " const xdg = process.env.XDG_STATE_HOME ?? '';", + " const stateHome = isAbsolute(xdg) ? join(xdg, 'agent-bundle') : join(homedir(), '.agent-bundle', 'state');", + " const digest = createHash('sha256').update(canonical).digest('hex').slice(0, 16);", + " const name = /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$/u.test(basename(canonical)) ? basename(canonical) : 'plugin';", + ' const derived = join(stateHome, `${name}-${digest}`);', + " if (servers.length === 0) {", + " const inherited = process.env.AGENT_BUNDLE_STATE_ROOT;", + " if (typeof inherited !== 'string' || inherited.trim() === '') return [{ root: derived, server: 'default', source: 'derived' }];", + ' const expanded = expandStatePath(inherited, canonical);', + " if (/\\$\\{[^}]*\\}/u.test(expanded)) return [{ root: derived, server: 'default', source: 'derived' }];", + " return isAbsolute(expanded) ? [{ root: resolve(expanded), server: 'default', source: 'declared' }] : [];", + ' }', + ' const locations = [];', + ' for (const [server, definition] of servers) {', + " const value = definition?.env?.AGENT_BUNDLE_STATE_ROOT ?? process.env.AGENT_BUNDLE_STATE_ROOT;", + " if (typeof value !== 'string' || value.trim() === '') { locations.push({ root: derived, server, source: 'derived' }); continue; }", + ' const expanded = expandStatePath(value, canonical);', + " if (/\\$\\{[^}]*\\}/u.test(expanded)) { locations.push({ root: derived, server, source: 'derived' }); continue; }", + ' if (isAbsolute(expanded)) { locations.push({ root: resolve(expanded), server, source: \'declared\' }); continue; }', + " if (typeof definition?.cwd !== 'string') continue;", + ' const expandedCwd = expandStatePath(definition.cwd, canonical);', + ' if (/\\$\\{[^}]*\\}/u.test(expandedCwd)) continue;', + ' const cwd = isAbsolute(expandedCwd) ? resolve(expandedCwd) : resolve(canonical, expandedCwd);', + " locations.push({ root: resolve(cwd, expanded), server, source: 'declared' });", + ' }', + ' return locations;', + '};', + 'const attachStateOwnership = async (previousState) => {', + ' const receipt = await readReceipt(destination);', + ' if (receipt === undefined) throw new Error(`Installed receipt is missing at ${destination}.`);', + ' const owner = previousState?.owner ?? { host: \'cursor\', id: randomUUID(), mode: \'local\', plugin: pluginName, scope: \'user\' };', + ' const grouped = new Map();', + ' for (const location of await stateLocations()) {', + ' const current = grouped.get(location.root);', + ' if (current === undefined) grouped.set(location.root, { ...location, servers: [location.server] });', + ' else current.servers.push(location.server);', + ' }', + ' const roots = [];', + ' const created = [];', + ' const markerOwns = async (marker) => {', + ' let document;', + " try { document = JSON.parse(await readFile(marker, 'utf8')); }", + " catch (error) { if (error?.code === 'ENOENT' || error instanceof SyntaxError) return false; throw error; }", + ' const actual = document?.owner;', + ' return document?.format === 1 && actual?.id === owner.id && actual?.host === owner.host && actual?.mode === owner.mode &&', + ' actual?.plugin === owner.plugin && actual?.scope === owner.scope && actual?.projectRoot === owner.projectRoot;', + ' };', + ' const rollbackCreated = async () => {', + ' for (const root of [...created].reverse()) {', + ' await rm(join(root, stateMarkerFile), { force: true });', + " try { await rmdir(root); } catch (error) { if (!['ENOENT', 'ENOTEMPTY'].includes(error?.code)) throw error; }", + ' }', + ' };', + ' try {', + ' for (const location of grouped.values()) {', + " if (location.source === 'derived') { roots.push({ canonicalRoot: await canonicalPath(location.root), ownership: { kind: 'derived' }, root: location.root, servers: location.servers, source: 'derived' }); continue; }", + ' try {', + ' const marker = join(location.root, stateMarkerFile);', + ' let existed = true;', + " try { await lstat(location.root); } catch (error) { if (error?.code !== 'ENOENT') throw error; existed = false; }", + ' let ownership;', + ' if (!existed) {', + ' await mkdir(dirname(location.root), { recursive: true });', + ' try {', + ' await mkdir(location.root);', + ' created.push(location.root);', + " const handle = await open(marker, 'wx');", + " try { await handle.writeFile(`${JSON.stringify({ format: 1, owner }, null, 2)}\\n`, 'utf8'); } finally { await handle.close(); }", + " ownership = { kind: 'marker', marker };", + ' } catch (error) {', + " if (error?.code !== 'EEXIST') {", + ' if (created.at(-1) === location.root) {', + ' created.pop();', + " try { await rmdir(location.root); } catch (rollbackError) { if (!['ENOENT', 'ENOTEMPTY'].includes(rollbackError?.code)) throw rollbackError; }", + ' }', + ' throw error;', + ' }', + ' if (created.at(-1) === location.root) created.pop();', + " ownership = await markerOwns(marker) ? { kind: 'marker', marker } : { kind: 'unowned', reason: 'foreign-marker' };", + ' }', + ' } else if (await markerOwns(marker)) ownership = { kind: \'marker\', marker };', + ' else {', + ' let markerExists = true;', + " try { await lstat(marker); } catch (error) { if (error?.code !== 'ENOENT') throw error; markerExists = false; }", + " ownership = { kind: 'unowned', reason: markerExists ? 'foreign-marker' : 'pre-existing' };", + ' }', + ' roots.push({ canonicalRoot: await canonicalPath(location.root), ownership, root: location.root, servers: location.servers, source: \'declared\' });', + ' } catch (error) {', + " if (!['EACCES', 'ENOTDIR', 'EPERM', 'EROFS'].includes(error?.code)) throw error;", + ' if (created.at(-1) === location.root) {', + ' created.pop();', + ' await rm(join(location.root, stateMarkerFile), { force: true });', + " try { await rmdir(location.root); } catch (rollbackError) { if (!['ENOENT', 'ENOTEMPTY'].includes(rollbackError?.code)) throw rollbackError; }", + ' }', + " roots.push({ canonicalRoot: resolve(location.root), ownership: { kind: 'unowned', reason: 'unproven' }, root: location.root, servers: location.servers, source: 'declared' });", + ' }', + ' }', + ' } catch (error) { await rollbackCreated(); throw error; }', + ' try {', + ' await writeReceiptFile(join(destination, receiptFile), `${JSON.stringify({ ...receipt, state: { owner, roots }, updatedAt: new Date().toISOString() }, null, 2)}\\n`);', + ' } catch (error) {', + ' await rollbackCreated();', + ' throw error;', + ' }', + '};', + '', '// Staged sibling copy on the destination filesystem so every later rename is atomic.', 'const stage = async (tree, receiptOptions = {}) => {', ' const parent = await mkdtemp(join(installRoot, `.${basename(destination)}.stage-`));', @@ -1280,6 +1462,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' const staged = await stage(artifact, { hostDirectories: createdHostDirectories });', ' try {', ' await rename(staged.root, destination);', + ' await attachStateOwnership();', ' console.log(`Installed ${pluginName}@${pluginVersion} at ${destination} (content ${short(artifact.hash)})`);', ' reportExpansion();', ' } finally {', @@ -1334,6 +1517,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' // Byte-identical pre-receipt copy: adoption only writes the receipt (adoption created no directories),', ' // through an exclusively created random sibling so no existing file or link is followed or overwritten.', ' await writeReceiptFile(join(destination, receiptFile), receiptFor(artifact, { directories: [], hostDirectories: [] }));', + ' await attachStateOwnership();', ' console.log(`Adopted ${pluginName}@${pluginVersion} at ${destination} (content ${short(artifact.hash)})`);', ' reportExpansion();', ' process.exit(0);', @@ -1345,6 +1529,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' directories: receipt.directories, hostDirectories: receipt.hostDirectories, installedAt: receipt.installedAt,', ' }));', ' }', + ' if (ownership === \'receipt\' && receipt.state === undefined) await attachStateOwnership();', ' console.log(`Already installed ${pluginName}@${pluginVersion} at ${destination} (content ${short(artifact.hash)})`);', ' process.exit(0);', '}', @@ -1428,6 +1613,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' directories, hostDirectories: previous?.hostDirectories ?? [], installedAt: previous?.installedAt,', " }), 'utf8');", ' await rename(join(staged.root, receiptFile), join(destination, receiptFile));', + ' await attachStateOwnership(previous?.state);', ' if (stateOnlyRemnant) console.log(`Installed ${pluginName}@${pluginVersion} at ${destination} (content ${short(artifact.hash)})`);', ' else console.log(`Replaced ${pluginName}@${pluginVersion} at ${destination} (content ${short(installedHash)} -> ${short(artifact.hash)})`);', ' reportExpansion();', diff --git a/packages/agent-bundle/src/install/uninstall.ts b/packages/agent-bundle/src/install/uninstall.ts index c7f81a5e1..953ef63ee 100644 --- a/packages/agent-bundle/src/install/uninstall.ts +++ b/packages/agent-bundle/src/install/uninstall.ts @@ -1,5 +1,5 @@ import type { Stats } from 'node:fs'; -import { lstat, readdir, readFile, rm } from 'node:fs/promises'; +import { lstat, readdir, readFile, rm, rmdir } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join } from 'node:path'; @@ -59,7 +59,7 @@ import { type InstallRegistration, type StoredInstallReceipt, } from './receipt.ts'; -import { installedWebDataRoot, type InstalledStateRoot, resolveInstalledStateRoot } from './state-root.ts'; +import { inspectInstalledStateOwnership, installedWebDataRoot, resolveInstalledStateRoot } from './state-root.ts'; /** * `agent-bundle uninstall ` (#101): the receipt-owned reverse of @@ -97,6 +97,8 @@ export interface UninstallDataReport { /** The durable-state paths the decision applied to (absolute). */ readonly paths: readonly string[]; readonly policy: UninstallDataPolicy; + /** Recorded state roots retained because this installation lacks valid deletion authority. */ + readonly retained?: readonly { readonly path: string; readonly reason: string }[]; } /** @@ -437,10 +439,11 @@ interface CursorLocalData { 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; + readonly emptyStateFiles: readonly string[]; + readonly emptyStateRoots: readonly string[]; /** Whether any durable state root exists. */ readonly present: boolean; readonly report: UninstallDataReport; - readonly stateRoot: InstalledStateRoot; readonly webDataRoot: string; } @@ -454,18 +457,37 @@ const cursorLocalData = async ( home: string, ): Promise => { const stateDirectory = join(destination, 'state'); - const effectiveState = receipt?.stateRoot ?? - await resolveInstalledStateRoot(destination, 'cursor', environment, home); const webData = receipt?.webDataRoot ?? installedWebDataRoot(destination, home); const paths: string[] = []; + const retainedState: { path: string; reason: string }[] = []; + const emptyStateFiles: string[] = []; + const emptyStateRoots: string[] = []; const kinds: string[] = []; let emptyState: string | undefined; - if ( - effectiveState.root !== stateDirectory && - await realDirectory(effectiveState.root, 'cursor') !== undefined - ) { - paths.push(effectiveState.root); - kinds.push(`${effectiveState.source} framework state root ${effectiveState.root}`); + if (receipt?.state !== undefined) { + for (const root of receipt.state.roots) { + if (root.root === stateDirectory) continue; + const decision = await inspectInstalledStateOwnership(receipt.state, root); + if (decision.action === 'purge') { + paths.push(root.root); + kinds.push(`${root.source} framework state root ${root.root}`); + } else if (decision.action === 'empty') { + emptyStateRoots.push(root.root); + if (decision.marker !== undefined) emptyStateFiles.push(decision.marker); + } else if (decision.action === 'retain') { + retainedState.push({ path: root.root, reason: decision.reason ?? 'unproven' }); + } + } + } 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') { + paths.push(observed.root); + kinds.push(`derived framework state root ${observed.root}`); + } else { + retainedState.push({ path: observed.root, reason: 'unproven' }); + } + } } if (await realDirectory(stateDirectory, 'cursor') !== undefined) { if ((await readdir(stateDirectory)).length === 0) { @@ -500,10 +522,12 @@ const cursorLocalData = async ( const foreignNote = foreignPluginData === undefined ? '' : ` The receipt records PLUGIN_DATA at ${foreignPluginData}, outside this home's agent-bundle/plugin-data; it is not touched.`; - if (paths.length === 0) { + if (paths.length === 0 && retainedState.length === 0) { return { ...(emptyPluginData === undefined ? {} : { emptyPluginData }), ...(emptyState === undefined ? {} : { emptyState }), + emptyStateFiles: Object.freeze(emptyStateFiles), + emptyStateRoots: Object.freeze(emptyStateRoots), present: false, report: Object.freeze({ detail: `No durable runtime state exists (${ @@ -515,23 +539,29 @@ const cursorLocalData = async ( paths: Object.freeze([]), policy, }), - stateRoot: effectiveState, webDataRoot: webData, }; } + const retainedNote = retainedState.length === 0 + ? '' + : ` Retained ${retainedState.map((entry) => `${entry.path} (${entry.reason})`).join(', ')} because the receipt does not prove exclusive ownership.`; return { ...(emptyPluginData === undefined ? {} : { emptyPluginData }), ...(emptyState === undefined ? {} : { emptyState }), + emptyStateFiles: Object.freeze(emptyStateFiles), + emptyStateRoots: Object.freeze(emptyStateRoots), present: true, report: Object.freeze({ detail: policy === 'purge' - ? `Durable runtime state — ${kinds.join(' and ')} — is removed (--purge-data --confirm-purge).${foreignNote}` - : `Durable runtime state — ${kinds.join(' and ')} — is kept; pass --purge-data --confirm-purge to remove it.${foreignNote}`, - outcome: policy === 'purge' ? 'purged' : 'kept', + ? `${paths.length === 0 ? 'No owned durable runtime state is removed.' : `Durable runtime state — ${kinds.join(' and ')} — is removed (--purge-data --confirm-purge).`}${retainedNote}${foreignNote}` + : `Durable runtime state${kinds.length === 0 ? '' : ` — ${kinds.join(' and ')}`} — is kept; pass --purge-data --confirm-purge to remove owned roots.${retainedNote}${foreignNote}`, + outcome: policy === 'purge' && paths.length > 0 ? 'purged' : 'kept', paths: Object.freeze(paths), policy, + ...(retainedState.length === 0 + ? {} + : { retained: Object.freeze(retainedState.map((entry) => Object.freeze(entry))) }), }), - stateRoot: effectiveState, webDataRoot: webData, }; }; @@ -599,11 +629,13 @@ const uninstallCursorLocal = async ( if (metadata.isSymbolicLink() || !metadata.isFile()) throw unsupportedEntry(path, 'cursor'); files.push(path); } + 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. const keepRoot = policy === 'keep' && - data.report.paths.some((path) => path !== join(destination, 'state')); + [...data.report.paths, ...(data.report.retained ?? []).map((entry) => entry.path)] + .some((path) => path !== join(destination, 'state')); const pluginDataRecorded = ownership.receipt?.cursorExpansion?.pluginData === cursorPluginDataDirectory(cursorRoot, identity.plugin); const directoryCandidates = [ ...ownership.directories.map((directory) => join(destination, directory)), @@ -613,6 +645,7 @@ const uninstallCursorLocal = async ( // receipts, marketplaces, or another plugin's data keep them alive. ...(data.emptyPluginData === undefined ? [] : [data.emptyPluginData]), ...(data.emptyState === undefined ? [] : [data.emptyState]), + ...data.emptyStateRoots, ...(pluginDataRecorded ? [join(cursorRoot, 'agent-bundle', 'plugin-data'), join(cursorRoot, 'agent-bundle')] : []), ]; const ownedDirectories = new Set(ownership.directories); @@ -693,7 +726,8 @@ const uninstallCursorLocal = async ( plugin: identity.plugin, registrations: [], scope: 'user', - ...(keepRoot ? { stateRoot: data.stateRoot, webDataRoot: data.webDataRoot } : {}), + ...(ownership.receipt?.state === undefined ? {} : { state: ownership.receipt.state }), + ...(keepRoot ? { webDataRoot: data.webDataRoot } : {}), updatedAt: new Date().toISOString(), version: ownership.receipt?.version ?? identity.version, })); @@ -1092,6 +1126,12 @@ const marketplaceDependents = async ( }); }; +interface PublicHostData { + readonly emptyStateFiles: readonly string[]; + readonly emptyStateRoots: readonly string[]; + readonly report: UninstallDataReport; +} + const publicHostData = async ( host: Exclude, policy: UninstallDataPolicy, @@ -1101,43 +1141,76 @@ const publicHostData = async ( sharedWith: readonly string[] | 'unknown', environment: Readonly, home: string, -): Promise => { + receipt: InstallReceipt | undefined, +): Promise => { const paths: string[] = []; + const retainedState: { path: string; reason: string }[] = []; + const emptyStateFiles: string[] = []; + const emptyStateRoots: string[] = []; + if (receipt?.state !== undefined) { + for (const root of receipt.state.roots) { + const decision = await inspectInstalledStateOwnership(receipt.state, root); + if (decision.action === 'purge') paths.push(decision.path); + if (decision.action === 'empty') { + emptyStateRoots.push(decision.path); + if (decision.marker !== undefined) emptyStateFiles.push(decision.marker); + } + if (decision.action === 'retain') { + retainedState.push({ path: decision.path, reason: decision.reason ?? 'unproven' }); + } + } + } if (entry !== undefined) { const legacyStateRoot = join(entry.installPath, 'state'); - const effectiveState = await resolveInstalledStateRoot(entry.installPath, host, environment, home); const candidates = [ - effectiveState.root, ...(host === 'codex' && policy === 'keep' ? [] : [legacyStateRoot]), installedWebDataRoot(entry.installPath, home), ]; for (const path of candidates) { if (!paths.includes(path) && await realDirectory(path, host) !== undefined) paths.push(path); } + if (receipt?.state === undefined) { + const observed = await resolveInstalledStateRoot(entry.installPath, host, environment, home); + if ( + !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' }); + } + } } if (host === 'claude') { const dataDirectory = join(hostRoot, 'plugins', 'data', id); if (await realDirectory(dataDirectory, host) !== undefined) paths.push(dataDirectory); } - if (paths.length === 0) { + if (paths.length === 0 && retainedState.length === 0) { if (host === 'codex' && entry !== undefined) { return Object.freeze({ - detail: policy === 'purge' - ? '`codex plugin remove` deletes the cached plugin tree; no external framework state or web-data exists.' - : '`codex plugin remove` deletes the cached plugin tree and Codex exposes no keep-data option; no external framework state or web-data exists to preserve.', - outcome: policy === 'purge' ? 'removed-by-host' : 'unavailable', - paths: Object.freeze([]), - policy, + emptyStateFiles: Object.freeze(emptyStateFiles), + emptyStateRoots: Object.freeze(emptyStateRoots), + report: Object.freeze({ + detail: policy === 'purge' + ? '`codex plugin remove` deletes the cached plugin tree; no external framework state or web-data exists.' + : '`codex plugin remove` deletes the cached plugin tree and Codex exposes no keep-data option; no external framework state or web-data exists to preserve.', + outcome: policy === 'purge' ? 'removed-by-host' : 'unavailable', + paths: Object.freeze([]), + policy, + }), }); } return Object.freeze({ - detail: 'No durable runtime state exists for the installed copy.', - outcome: 'absent', - paths: Object.freeze([]), - policy, + emptyStateFiles: Object.freeze(emptyStateFiles), + emptyStateRoots: Object.freeze(emptyStateRoots), + report: Object.freeze({ + detail: 'No durable runtime state exists for the installed copy.', + outcome: 'absent', + paths: Object.freeze([]), + policy, + }), }); } - if (policy === 'purge' && (sharedWith === 'unknown' || sharedWith.length > 0)) { + if (policy === 'purge' && paths.length > 0 && (sharedWith === 'unknown' || sharedWith.length > 0)) { // The cache copy and plugins/data/ are scope-less: another scope's install still uses them. throw failure( 'AB7008', @@ -1150,14 +1223,29 @@ const publicHostData = async ( ); } return Object.freeze({ - detail: policy === 'purge' - ? `Durable runtime state is removed after the ${host} uninstall returns (--purge-data --confirm-purge).` - : host === 'claude' - ? '`claude plugin uninstall --keep-data` orphans the cached copy for Claude\'s ~14-day grace period; Agent Bundle preserves the effective framework state root, legacy state/, web-data, and plugins/data.' - : '`codex plugin remove` deletes the cached plugin tree, but Agent Bundle preserves the external framework state root and web-data.', - outcome: policy === 'purge' ? 'purged' : host === 'claude' ? 'retained-by-host' : 'kept', - paths: Object.freeze(paths), - policy, + emptyStateFiles: Object.freeze(emptyStateFiles), + emptyStateRoots: Object.freeze(emptyStateRoots), + report: Object.freeze({ + detail: policy === 'purge' + ? `${paths.length === 0 + ? 'No owned durable runtime state is removed.' + : `Owned durable runtime state is removed after the ${host} uninstall returns (--purge-data --confirm-purge).`}${ + retainedState.length === 0 + ? '' + : ` Retained ${retainedState.map((entry) => `${entry.path} (${entry.reason})`).join(', ')} because the receipt does not prove exclusive ownership.` + }` + : host === 'claude' + ? '`claude plugin uninstall --keep-data` orphans the cached copy for Claude\'s ~14-day grace period; Agent Bundle preserves the effective framework state root, legacy state/, web-data, and plugins/data.' + : '`codex plugin remove` deletes the cached plugin tree, but Agent Bundle preserves the external framework state root and web-data.', + outcome: policy === 'purge' + ? paths.length > 0 ? 'purged' : 'kept' + : host === 'claude' ? 'retained-by-host' : 'kept', + paths: Object.freeze(paths), + policy, + ...(retainedState.length === 0 + ? {} + : { retained: Object.freeze(retainedState.map((entry) => Object.freeze(entry))) }), + }), }); }; @@ -1308,6 +1396,7 @@ const uninstallPublicCli = async ( dependents === 'unknown' ? 'unknown' : dependents.sameOtherScopes, environment, home, + receipt, ); const registrations: UninstallRegistrationReport[] = []; if (pluginRegistration !== undefined) { @@ -1343,24 +1432,38 @@ const uninstallPublicCli = async ( : `\`${host} ${publicHostMarketplaceRemoveArguments(marketplace).join(' ')}\``, })); } - const purgedDirectories = policy === 'purge' && data.outcome === 'purged' ? data.paths : []; + const purgedDirectories = policy === 'purge' && data.report.outcome === 'purged' + ? data.report.paths + : []; + const keepReceipt = policy === 'keep' && + receipt !== undefined && + (data.report.paths.length > 0 || (data.report.retained?.length ?? 0) > 0); const result = { ...base, - data, + data: data.report, ...(entry === undefined ? {} : { destination: entry.installPath }), - receipt: receiptReport(receiptPath, receipt, status), + receipt: receiptReport(receiptPath, receipt, keepReceipt ? 'remnant' : status), registrations: Object.freeze(registrations), retained: Object.freeze([]), } as const; if (planned) { // The store pruning the run below performs, simulated: the receipt file, then the store directories it // leaves empty, so the plan names every path the completed result would. - const wouldRemove = await simulateRemoveStoredInstallReceipt(receiptPath, hostRoot); + const wouldRemove = keepReceipt + ? Object.freeze([]) + : await simulateRemoveStoredInstallReceipt(receiptPath, hostRoot); return Object.freeze({ ...result, removed: Object.freeze({ - directories: Object.freeze([...purgedDirectories, ...wouldRemove.filter((path) => path !== receiptPath)]), - files: Object.freeze(wouldRemove.filter((path) => path === receiptPath)), + directories: Object.freeze([ + ...purgedDirectories, + ...data.emptyStateRoots, + ...wouldRemove.filter((path) => path !== receiptPath), + ]), + files: Object.freeze([ + ...data.emptyStateFiles, + ...wouldRemove.filter((path) => path === receiptPath), + ]), }), state: 'planned', }); @@ -1372,6 +1475,12 @@ const uninstallPublicCli = async ( await runHostCommand(runner, identity, host, publicHostMarketplaceRemoveArguments(marketplace), 'removal'); } for (const path of purgedDirectories) await rm(path, { force: true, recursive: true }); + for (const path of data.emptyStateFiles) await rm(path, { force: true }); + for (const path of data.emptyStateRoots) { + await rmdir(path).catch((error: unknown) => { + if (!isErrno(error, 'ENOENT')) throw error; + }); + } if (ownershipHeir !== undefined && ownershipHeir !== 'already-recorded' && ownershipHeir !== 'none') { const heirRegistration = publicHostRegistrations(host, id, marketplace, ownershipHeir.receipt.scope) .find((registration) => registration.kind === `${host}-marketplace`); @@ -1383,12 +1492,32 @@ const uninstallPublicCli = async ( }); } } - const removedReceipt = await removeStoredInstallReceipt(receiptPath, hostRoot); + let removedReceipt: readonly string[]; + if (keepReceipt && receipt !== undefined) { + await writeStoredInstallReceipt(receiptPath, { + ...receipt, + directories: Object.freeze([]), + files: Object.freeze([]), + hostDirectories: Object.freeze([]), + registrations: Object.freeze([]), + updatedAt: new Date().toISOString(), + }); + removedReceipt = Object.freeze([]); + } else { + removedReceipt = await removeStoredInstallReceipt(receiptPath, hostRoot); + } return Object.freeze({ ...result, removed: Object.freeze({ - directories: Object.freeze([...purgedDirectories, ...removedReceipt.filter((path) => path !== receiptPath)]), - files: Object.freeze(removedReceipt.filter((path) => path === receiptPath)), + directories: Object.freeze([ + ...purgedDirectories, + ...data.emptyStateRoots, + ...removedReceipt.filter((path) => path !== receiptPath), + ]), + files: Object.freeze([ + ...data.emptyStateFiles, + ...removedReceipt.filter((path) => path === receiptPath), + ]), }), state: 'uninstalled', }); diff --git a/packages/agent-bundle/tests/doctor.test.ts b/packages/agent-bundle/tests/doctor.test.ts index 193353700..487102168 100644 --- a/packages/agent-bundle/tests/doctor.test.ts +++ b/packages/agent-bundle/tests/doctor.test.ts @@ -555,6 +555,9 @@ it('inventories durable SQLite stores and sidecars without opening them', async }], status: 'known', summary: { bytes: 15, stores: 1 }, + ownership: 'unrecorded', + purgeable: false, + servers: ['default'], writable: true, }); expect(report.diagnostics).toEqual(expect.arrayContaining([ @@ -569,6 +572,7 @@ it('inventories durable SQLite stores and sidecars without opening them', async expect(humanCode).toBe(0); expect(human.stdout()).toContain('durable state: 1 store, 15 B'); expect(human.stdout()).toContain(`state root: ${stateRoot} (exists, writable, derived)`); + expect(human.stdout()).toContain('ownership: unrecorded, retained, servers: default'); const json = captureCliTerminal(); await runCli(['doctor', '--json'], json.output, { runDoctor: async () => report }); @@ -614,6 +618,9 @@ it('reports a missing derived state root and a declared state-root override', as directory: declaredStateRoot, exists: false, findings: [], + ownership: 'unrecorded', + purgeable: false, + servers: ['configured'], summary: { bytes: 0, stores: 0 }, writable: false, }); @@ -622,6 +629,46 @@ it('reports a missing derived state root and a declared state-root override', as } }); +it('reports an unresolved relative state override without treating the plugin root as state', async () => { + const fixture = await temporaryDoctor(); + const pluginRoot = join(fixture.home, '.cursor', 'plugins', 'local', 'relative-state'); + try { + await Promise.all([ + writeJson(join(pluginRoot, '.cursor-plugin/plugin.json'), { name: 'relative-state', version: '1.0.0' }), + writeJson(join(pluginRoot, '.cursor-plugin/mcp.json'), { + mcpServers: { + configured: { + command: 'node', + env: { AGENT_BUNDLE_STATE_ROOT: '../state' }, + }, + }, + }), + ]); + await writeInstallFixtureManifest( + pluginRoot, + { name: 'relative-state', version: '1.0.0' }, + [{ host: 'cursor', mcp: '.cursor-plugin/mcp.json' }], + ); + const report = await runDoctor({ + endpointDirectory: fixture.endpointDirectory, + home: fixture.home, + hosts: ['cursor'], + }); + const finding = hostReport(report, 'cursor').inventory.findings.find((entry) => entry.entry === 'relative-state'); + expect(finding?.durableState).toMatchObject({ + directory: '', + exists: false, + ownership: 'unrecorded', + ownershipReason: 'relative override has no provable execution directory', + purgeable: false, + servers: ['configured'], + }); + expect(finding?.durableState?.directory).not.toBe(pluginRoot); + } finally { + await fixture.cleanup(); + } +}); + it('reports whether an installed pack carries an operator .env file, never its contents (#469)', async () => { const fixture = await temporaryDoctor(); const pluginRoot = join(fixture.home, '.cursor', 'plugins', 'local', 'configured'); @@ -2157,6 +2204,11 @@ it('surfaces the placed → registered → enabled → active lifecycle per host }); expect(cursorPlaced.bundle?.receipt).toMatchObject({ mode: 'local', scope: 'user' }); expect(cursorPlaced.inventory.findings[0]?.receipt).toMatchObject({ mode: 'local' }); + expect(cursorPlaced.inventory.findings[0]?.durableState).toMatchObject({ + ownership: 'derived', + purgeable: false, + servers: ['default'], + }); } 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 6a256e6c1..6ae781c77 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -717,6 +717,76 @@ it('emitted install.mjs mirrors the core replace policy: no-op, owned-only repla } }, 60_000); +it('emitted install.mjs marks new explicit state roots and retains pre-existing ones', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-state-ownership-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 ownedRoot = join(root, 'owned-state'); + 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, '.cursor-plugin', 'mcp.json'), JSON.stringify({ + mcpServers: { + stateful: { + command: 'node', + env: { AGENT_BUNDLE_STATE_ROOT: ownedRoot }, + }, + }, + })), + writeFile(join(bundle, 'payload.txt'), 'payload\n'), + ]); + expect((await run(installer, [], home)).code).toBe(0); + expect(await readInstallReceipt(destination)).toMatchObject({ + state: { + roots: [{ + ownership: { kind: 'marker', marker: join(ownedRoot, '.agent-bundle-state-owner.json') }, + root: ownedRoot, + servers: ['stateful'], + }], + }, + }); + const markerOnly = await run(installer, ['--uninstall'], home); + expect(markerOnly.code).toBe(0); + expect(markerOnly.stdout).not.toContain('Remnant receipt:'); + await expect(readdir(ownedRoot)).rejects.toMatchObject({ code: 'ENOENT' }); + + expect((await run(installer, [], home)).code).toBe(0); + await writeFile(join(ownedRoot, 'state.sqlite'), 'owned\n'); + expect((await run(installer, ['--uninstall', '--purge-data', '--confirm-purge'], home)).code).toBe(0); + await expect(readdir(ownedRoot)).rejects.toMatchObject({ code: 'ENOENT' }); + + const sharedRoot = join(root, 'shared-state'); + const sentinel = join(sharedRoot, 'sentinel.txt'); + await mkdir(sharedRoot); + await writeFile(sentinel, 'keep\n'); + await writeFile(join(bundle, '.cursor-plugin', 'mcp.json'), JSON.stringify({ + mcpServers: { + stateful: { + command: 'node', + env: { AGENT_BUNDLE_STATE_ROOT: sharedRoot }, + }, + }, + })); + expect((await run(installer, [], home)).code).toBe(0); + const retained = await run(installer, ['--uninstall', '--purge-data', '--confirm-purge'], home); + expect(retained.code).toBe(0); + expect(retained.stdout).toContain(`Retained ${sharedRoot} (pre-existing)`); + expect(await readFile(sentinel, 'utf8')).toBe('keep\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 35d7861c0..5e8a3d558 100644 --- a/packages/agent-bundle/tests/uninstall.test.ts +++ b/packages/agent-bundle/tests/uninstall.test.ts @@ -17,6 +17,7 @@ import { readInstallReceipt, readInstallReceiptFile, } from '../src/install/receipt.ts'; +import { recordInstalledState } from '../src/install/state-root.ts'; import { uninstallBundle, type UninstallResult } from '../src/install/uninstall.ts'; import { captureCliTerminal } from './support/cli-terminal.ts'; import { writeInstallFixtureManifest } from './support/install-fixture.ts'; @@ -92,6 +93,23 @@ const createFixture = async ( return { bundleRoot, cleanupRoot, home }; }; +const writeFixtureMcp = async ( + fixture: Fixture, + host: 'claude' | 'codex' | 'cursor', + document: unknown, +): Promise => { + await writeJson(join(fixture.bundleRoot, mcpDocuments[host]), document); + await writeInstallFixtureManifest( + fixture.bundleRoot, + { name: 'uninstall-fixture', version: '1.2.3' }, + [{ + host, + ...(host === 'cursor' ? {} : { marketplace: 'uninstall-fixture-marketplace' }), + mcp: mcpDocuments[host], + }], + ); +}; + const failureOf = async (promise: Promise): Promise => { const error = await promise.catch((thrown: unknown) => thrown); expect(error).toBeInstanceOf(DiagnosticError); @@ -432,7 +450,17 @@ it('purges AGENT_BUNDLE_STATE_ROOT from the host MCP document the installed mani try { await mkdir(cursorRoot, { recursive: true }); await installBundle(options); - await mkdir(declaredStateRoot, { recursive: true }); + expect(await readInstallReceipt(join(cursorRoot, 'plugins', 'local', 'uninstall-fixture'))) + .toMatchObject({ + state: { + roots: [{ + ownership: { kind: 'marker', marker: join(declaredStateRoot, '.agent-bundle-state-owner.json') }, + root: declaredStateRoot, + servers: ['stateful'], + source: 'declared', + }], + }, + }); await writeFile(join(declaredStateRoot, 'plugin.sqlite'), 'declared\n'); const plan = await uninstallBundle({ ...options, confirmPurge: true, plan: true, purgeData: true }); expect(plan.data.paths).toEqual([declaredStateRoot]); @@ -443,7 +471,7 @@ it('purges AGENT_BUNDLE_STATE_ROOT from the host MCP document the installed mani remnantReceipt: join(cursorRoot, 'plugins', 'local', 'uninstall-fixture', installReceiptFile), }); expect(await readInstallReceipt(join(cursorRoot, 'plugins', 'local', 'uninstall-fixture'))) - .toMatchObject({ stateRoot: { root: declaredStateRoot, source: 'native' } }); + .toMatchObject({ state: { roots: [{ root: declaredStateRoot, source: 'declared' }] } }); expect(await readFile(join(declaredStateRoot, 'plugin.sqlite'), 'utf8')).toBe('declared\n'); const purged = await uninstallBundle({ ...options, confirmPurge: true, purgeData: true }); expect(purged.data).toMatchObject({ outcome: 'purged', paths: [declaredStateRoot] }); @@ -456,6 +484,366 @@ it('purges AGENT_BUNDLE_STATE_ROOT from the host MCP document the installed mani } }); +it('never purges a pre-existing declared state root or its unrelated sentinel', async () => { + const fixture = await createFixture('cursor'); + const cursorRoot = join(fixture.home, '.cursor'); + const sharedRoot = join(fixture.cleanupRoot, 'shared-state'); + const sentinel = join(sharedRoot, 'unrelated.txt'); + const options = { from: fixture.bundleRoot, home: fixture.home, host: 'cursor' as const }; + try { + await Promise.all([ + mkdir(cursorRoot, { recursive: true }), + mkdir(sharedRoot, { recursive: true }), + writeFixtureMcp(fixture, 'cursor', { + mcpServers: { + stateful: { + command: 'node', + env: { AGENT_BUNDLE_STATE_ROOT: sharedRoot }, + }, + }, + }), + ]); + await writeFile(sentinel, 'keep\n'); + await installBundle(options); + const plan = await uninstallBundle({ ...options, confirmPurge: true, plan: true, purgeData: true }); + expect(plan.data).toMatchObject({ + outcome: 'kept', + paths: [], + retained: [{ path: sharedRoot, reason: 'pre-existing' }], + }); + expect(plan.removed.directories).not.toContain(sharedRoot); + const kept = await uninstallBundle(options); + expect(kept.remnantReceipt).toBe(join( + cursorRoot, + 'plugins', + 'local', + 'uninstall-fixture', + installReceiptFile, + )); + expect((await readInstallReceipt(join( + cursorRoot, + 'plugins', + 'local', + 'uninstall-fixture', + )))?.state?.roots[0]).toMatchObject({ root: sharedRoot }); + await uninstallBundle({ ...options, confirmPurge: true, purgeData: true }); + expect(await readFile(sentinel, 'utf8')).toBe('keep\n'); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + +it('prunes a newly marked explicit root when no runtime state was written', async () => { + const fixture = await createFixture('cursor'); + const cursorRoot = join(fixture.home, '.cursor'); + const declaredRoot = join(fixture.cleanupRoot, 'unused-state'); + const options = { from: fixture.bundleRoot, home: fixture.home, host: 'cursor' as const }; + try { + await Promise.all([ + mkdir(cursorRoot, { recursive: true }), + writeFixtureMcp(fixture, 'cursor', { + mcpServers: { + stateful: { + command: 'node', + env: { AGENT_BUNDLE_STATE_ROOT: declaredRoot }, + }, + }, + }), + ]); + await installBundle(options); + expect(await readdir(declaredRoot)).toEqual(['.agent-bundle-state-owner.json']); + const removed = await uninstallBundle(options); + expect(removed.data.outcome).toBe('absent'); + expect(removed.remnantReceipt).toBeUndefined(); + await expect(readdir(declaredRoot)).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + +it('records an inaccessible declared root as unproven without failing installation', async () => { + const fixture = await createFixture('cursor'); + const cursorRoot = join(fixture.home, '.cursor'); + const blockedParent = join(fixture.cleanupRoot, 'not-a-directory'); + try { + await Promise.all([ + mkdir(cursorRoot, { recursive: true }), + writeFile(blockedParent, 'file\n'), + writeFixtureMcp(fixture, 'cursor', { + mcpServers: { + stateful: { + command: 'node', + env: { AGENT_BUNDLE_STATE_ROOT: join(blockedParent, 'state') }, + }, + }, + }), + ]); + const installed = await installBundle({ + from: fixture.bundleRoot, + home: fixture.home, + host: 'cursor', + }); + if (installed.destination === undefined) throw new Error('Cursor install did not report its destination.'); + expect((await readInstallReceipt(installed.destination))?.state?.roots).toMatchObject([{ + ownership: { kind: 'unowned', reason: 'unproven' }, + root: join(blockedParent, 'state'), + }]); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + +it('rolls back earlier state markers when a later root cannot be recorded', async () => { + const fixture = await createFixture('cursor'); + const firstRoot = join(fixture.cleanupRoot, 'first-state'); + const invalidRoot = join(fixture.cleanupRoot, 'x'.repeat(300)); + try { + await writeFixtureMcp(fixture, 'cursor', { + mcpServers: { + first: { + command: 'node', + env: { AGENT_BUNDLE_STATE_ROOT: firstRoot }, + }, + second: { + command: 'node', + env: { AGENT_BUNDLE_STATE_ROOT: invalidRoot }, + }, + }, + }); + await expect(recordInstalledState({ + environment: {}, + home: fixture.home, + host: 'cursor', + mode: 'local', + plugin: 'uninstall-fixture', + pluginRoot: fixture.bundleRoot, + scope: 'user', + })).rejects.toMatchObject({ code: 'ENAMETOOLONG' }); + await expect(readdir(firstRoot)).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + +it('purges only the install-time AGENT_BUNDLE_STATE_ROOT when the uninstall environment changes', async () => { + const fixture = await createFixture('cursor'); + const cursorRoot = join(fixture.home, '.cursor'); + const recordedRoot = join(fixture.cleanupRoot, 'install-state-root'); + const unrelatedRoot = join(fixture.cleanupRoot, 'uninstall-state-root'); + const installEnvironment = { AGENT_BUNDLE_STATE_ROOT: recordedRoot }; + const uninstallEnvironment = { AGENT_BUNDLE_STATE_ROOT: unrelatedRoot }; + const sentinel = join(unrelatedRoot, 'unrelated.txt'); + try { + await mkdir(cursorRoot, { recursive: true }); + await installBundle({ environment: installEnvironment, from: fixture.bundleRoot, home: fixture.home, host: 'cursor' }); + await mkdir(unrelatedRoot, { recursive: true }); + await Promise.all([ + writeFile(join(recordedRoot, 'plugin.sqlite'), 'owned\n'), + writeFile(sentinel, 'keep\n'), + ]); + const purged = await uninstallBundle({ + confirmPurge: true, + environment: uninstallEnvironment, + from: fixture.bundleRoot, + home: fixture.home, + host: 'cursor', + purgeData: true, + }); + expect(purged.data.paths).toContain(recordedRoot); + expect(purged.data.paths).not.toContain(unrelatedRoot); + await expect(readdir(recordedRoot)).rejects.toMatchObject({ code: 'ENOENT' }); + expect(await readFile(sentinel, 'utf8')).toBe('keep\n'); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + +it('records and purges each server state root using its execution cwd', async () => { + const fixture = await createFixture('cursor'); + const cursorRoot = join(fixture.home, '.cursor'); + const destination = join(cursorRoot, 'plugins', 'local', 'uninstall-fixture'); + const firstRoot = join(fixture.cleanupRoot, 'first-state'); + const relativeRoot = join(destination, 'shared-state'); + try { + await Promise.all([ + mkdir(cursorRoot, { recursive: true }), + writeFixtureMcp(fixture, 'cursor', { + mcpServers: { + alpha: { + command: 'node', + env: { AGENT_BUNDLE_STATE_ROOT: firstRoot }, + }, + beta: { + command: 'node', + cwd: '${CURSOR_PLUGIN_ROOT}/runtime', + env: { AGENT_BUNDLE_STATE_ROOT: '../shared-state' }, + }, + }, + }), + ]); + await installBundle({ from: fixture.bundleRoot, home: fixture.home, host: 'cursor' }); + expect((await readInstallReceipt(destination))?.state?.roots).toEqual([ + expect.objectContaining({ root: firstRoot, servers: ['alpha'], source: 'declared' }), + expect.objectContaining({ root: relativeRoot, servers: ['beta'], source: 'declared' }), + ]); + await Promise.all([ + writeFile(join(firstRoot, 'alpha.sqlite'), 'alpha\n'), + writeFile(join(relativeRoot, 'beta.sqlite'), 'beta\n'), + ]); + const purged = await uninstallBundle({ + confirmPurge: true, + from: fixture.bundleRoot, + home: fixture.home, + host: 'cursor', + purgeData: true, + }); + expect(purged.data.paths).toEqual([firstRoot, relativeRoot]); + await expect(readdir(firstRoot)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(readdir(relativeRoot)).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + +it('retains a marked root when its marker is replaced by another install identity', async () => { + const fixture = await createFixture('cursor'); + const cursorRoot = join(fixture.home, '.cursor'); + const declaredRoot = join(fixture.cleanupRoot, 'marked-state'); + const marker = join(declaredRoot, '.agent-bundle-state-owner.json'); + try { + await Promise.all([ + mkdir(cursorRoot, { recursive: true }), + writeFixtureMcp(fixture, 'cursor', { + mcpServers: { + stateful: { + command: 'node', + env: { AGENT_BUNDLE_STATE_ROOT: declaredRoot }, + }, + }, + }), + ]); + await installBundle({ from: fixture.bundleRoot, home: fixture.home, host: 'cursor' }); + await writeJson(marker, { + format: 1, + owner: { host: 'cursor', id: 'another-install', mode: 'local', plugin: 'other', scope: 'user' }, + }); + const sentinel = join(declaredRoot, 'sentinel.txt'); + await writeFile(sentinel, 'keep\n'); + const result = await uninstallBundle({ + confirmPurge: true, + from: fixture.bundleRoot, + home: fixture.home, + host: 'cursor', + purgeData: true, + }); + expect(result.data).toMatchObject({ + outcome: 'kept', + retained: [{ path: declaredRoot, reason: 'marker-mismatch' }], + }); + expect(await readFile(sentinel, 'utf8')).toBe('keep\n'); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + +it('lets only the owning installation purge a root shared by two installs', async () => { + const owner = await createFixture('cursor'); + const observer = await createFixture('cursor'); + const sharedRoot = join(owner.cleanupRoot, 'shared-state'); + const manifest = { + mcpServers: { + stateful: { + command: 'node', + env: { AGENT_BUNDLE_STATE_ROOT: sharedRoot }, + }, + }, + }; + try { + await Promise.all([ + mkdir(join(owner.home, '.cursor'), { recursive: true }), + mkdir(join(observer.home, '.cursor'), { recursive: true }), + writeFixtureMcp(owner, 'cursor', manifest), + writeFixtureMcp(observer, 'cursor', manifest), + ]); + await installBundle({ from: owner.bundleRoot, home: owner.home, host: 'cursor' }); + await installBundle({ from: observer.bundleRoot, home: observer.home, host: 'cursor' }); + const observerRoot = join(observer.home, '.cursor', 'plugins', 'local', 'uninstall-fixture'); + expect((await readInstallReceipt(observerRoot))?.state?.roots[0]?.ownership).toEqual({ + kind: 'unowned', + reason: 'foreign-marker', + }); + const sentinel = join(sharedRoot, 'sentinel.txt'); + await writeFile(sentinel, 'keep\n'); + const retained = await uninstallBundle({ + confirmPurge: true, + from: observer.bundleRoot, + home: observer.home, + host: 'cursor', + purgeData: true, + }); + expect(retained.data.retained).toEqual([{ path: sharedRoot, reason: 'foreign-marker' }]); + expect(await readFile(sentinel, 'utf8')).toBe('keep\n'); + await uninstallBundle({ + confirmPurge: true, + from: owner.bundleRoot, + home: owner.home, + host: 'cursor', + purgeData: true, + }); + await expect(readdir(sharedRoot)).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + await Promise.all([ + rm(owner.cleanupRoot, { force: true, recursive: true }), + rm(observer.cleanupRoot, { force: true, recursive: true }), + ]); + } +}); + +it('retains a marked root when a symlinked ancestor is retargeted', async () => { + const fixture = await createFixture('cursor'); + const cursorRoot = join(fixture.home, '.cursor'); + const firstTarget = join(fixture.cleanupRoot, 'first-target'); + const secondTarget = join(fixture.cleanupRoot, 'second-target'); + const linkedBase = join(fixture.cleanupRoot, 'state-link'); + const declaredRoot = join(linkedBase, 'owned-state'); + try { + await Promise.all([ + mkdir(cursorRoot, { recursive: true }), + mkdir(firstTarget), + mkdir(secondTarget), + writeFixtureMcp(fixture, 'cursor', { + mcpServers: { + stateful: { + command: 'node', + env: { AGENT_BUNDLE_STATE_ROOT: declaredRoot }, + }, + }, + }), + ]); + await symlink(firstTarget, linkedBase); + await installBundle({ from: fixture.bundleRoot, home: fixture.home, host: 'cursor' }); + await rm(linkedBase); + await mkdir(join(secondTarget, 'owned-state')); + const sentinel = join(secondTarget, 'owned-state', 'sentinel.txt'); + await writeFile(sentinel, 'keep\n'); + await symlink(secondTarget, linkedBase); + const result = await uninstallBundle({ + confirmPurge: true, + from: fixture.bundleRoot, + home: fixture.home, + host: 'cursor', + purgeData: true, + }); + expect(result.data).toMatchObject({ + outcome: 'kept', + retained: [{ path: declaredRoot, reason: 'canonical-path-changed' }], + }); + expect(await readFile(sentinel, 'utf8')).toBe('keep\n'); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + it('refuses Cursor local uninstalls without proof of ownership unless forced, and foreign directories always', async () => { const fixture = await createFixture('cursor'); const cursorRoot = join(fixture.home, '.cursor'); @@ -1252,6 +1640,58 @@ it('purges Claude durable state only when confirmed and reports the host-retaine } }); +it('reacquires Claude marketplace ownership after a keep-data remnant reinstall', 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'); + let installed = false; + let marketplaceRegistered = 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(marketplaceRegistered ? [{ name: 'uninstall-fixture-marketplace' }] : []); + } + if (verb === `plugin marketplace add ${fixture.bundleRoot}`) marketplaceRegistered = true; + if (verb.startsWith('plugin marketplace remove ')) marketplaceRegistered = false; + if (verb.startsWith('plugin install ')) installed = true; + if (verb.startsWith('plugin uninstall ')) installed = false; + return ''; + }); + const options = { + commandRunner: runner, + environment: { CLAUDE_CONFIG_DIR: hostRoot }, + from: fixture.bundleRoot, + home: fixture.home, + host: 'claude' as const, + }; + try { + await installBundle(options); + await cp(fixture.bundleRoot, installPath, { recursive: true }); + const stateRoot = userDataStateRoot(installPath, options.environment, fixture.home); + await mkdir(stateRoot, { recursive: true }); + await writeFile(join(stateRoot, 'state.sqlite'), 'state\n'); + const plan = await uninstallBundle({ ...options, plan: true }); + const kept = await uninstallBundle(options); + expect(kept.receipt.status).toBe('remnant'); + expect(plan.removed).toEqual(kept.removed); + expect(marketplaceRegistered).toBe(false); + + await installBundle(options); + expect(marketplaceRegistered).toBe(true); + const removed = await uninstallBundle(options); + expect(removed.registrations.find((registration) => registration.kind === 'claude-marketplace')) + .toMatchObject({ action: 'removed' }); + expect(marketplaceRegistered).toBe(false); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + it('keeps external Codex state while reporting in-tree state only for purge', async () => { const fixture = await createFixture('codex'); const hostRoot = join(fixture.cleanupRoot, 'codex-root'); @@ -1284,6 +1724,7 @@ it('keeps external Codex state while reporting in-tree state only for purge', as mkdir(join(installPath, 'state'), { recursive: true }), mkdir(stateRoot, { recursive: true }), ]); + await writeFile(join(stateRoot, 'state.sqlite'), 'state\n'); expect((await uninstallBundle({ ...options, plan: true })).data).toMatchObject({ outcome: 'kept', paths: [stateRoot], diff --git a/website/docs/en/guide/distribution/installation.mdx b/website/docs/en/guide/distribution/installation.mdx index c1e0bc7e6..bc663c7e3 100644 --- a/website/docs/en/guide/distribution/installation.mdx +++ b/website/docs/en/guide/distribution/installation.mdx @@ -110,7 +110,9 @@ this plugin's installer did not place — is refused either way. Cursor copies c place and touches owned files only, never unowned entries such as legacy or in-place `state/`, and `--replace` adopts a pre-receipt copy. Current artifact builds keep framework state under `~/.agent-bundle/state/-` instead (`AGENT_BUNDLE_STATE_ROOT` overrides that -location); `uninstall --purge-data --confirm-purge` removes it for the installed code root. Claude replacement runs +location); `uninstall --purge-data --confirm-purge` removes only roots whose receipt proves that +installation owns them. Pre-existing, shared, marker-less, and otherwise unproven override roots +are retained. Claude replacement runs `claude plugin uninstall --keep-data` before reinstalling because `plugin update` is version-gated; Codex runs `codex plugin remove` before `add`. The emitted `INSTALL.md` documents the same recipe per host. diff --git a/website/docs/en/reference/cli.mdx b/website/docs/en/reference/cli.mdx index f60586890..ede079b1d 100644 --- a/website/docs/en/reference/cli.mdx +++ b/website/docs/en/reference/cli.mdx @@ -195,6 +195,9 @@ before `add`. Every install writes a lifecycle receipt (format `agent-bundle-ins version, content hash, mode, scope, owned paths, host registrations, timestamps) — in-tree for Cursor local copies, under `/agent-bundle/receipts/` for Claude, Codex, and Cursor marketplace mode — that `uninstall` and `doctor` consume. +During install, an explicit state root that does not yet exist is created and receives +`.agent-bundle-state-owner.json`, which records the installation identity. Parent directories may +be created to reach it but are never claimed or recursively removed. ## uninstall @@ -209,8 +212,8 @@ agent-bundle uninstall [--from ] [--scope ] [--mode ] | `--from ` | `process.cwd()` | The composite root whose `agent-bundle.manifest.json` identifies the plugin (name, version, marketplace), read exactly as `install` reads it (`AB7001` on the same conditions). | | `--scope ` | `user` | The scope the plugin was installed at (Claude). | | `--mode ` | `local` | Cursor only: uninstall the `local` copy or the staged `marketplace` repository. | -| `--keep-data` | on | Keep the effective framework state root (`AGENT_BUNDLE_STATE_ROOT`, else `~/.agent-bundle/state/-` or `$XDG_STATE_HOME/agent-bundle/-`), derived web-data, legacy `state/`, and a recorded Cursor `PLUGIN_DATA` directory. This is the default; the flag makes it explicit. | -| `--purge-data` | off | Remove those durable-data roots for the exact installed code root. Refused (`AB7008`) without `--confirm-purge`. | +| `--keep-data` | on | Keep every recorded framework state root, derived web-data, legacy `state/`, and a recorded Cursor `PLUGIN_DATA` directory. This is the default; the flag makes it explicit and preserves the ownership receipt for a later purge. | +| `--purge-data` | off | Remove only receipt-recorded, installation-owned durable-data roots. Refused (`AB7008`) without `--confirm-purge`; shared, externally managed, marker-less, foreign-marker, and otherwise unproven roots are retained and listed. | | `--force` | off | Proceed without a receipt (legacy Cursor copy, host-only install) or when owned content, version, or staged `HEAD` no longer matches the receipt. A receipt or manifest naming another plugin is refused regardless. | | `--plan` | off | Print the exact paths and host registrations that would be removed and change nothing. | @@ -230,6 +233,17 @@ and removes it on a confirmed purge while `codex plugin remove` deletes the cach `uninstall ` with the same flags; the emitted `install.mjs` accepts `--uninstall` with `--mode`, `--keep-data`, `--purge-data --confirm-purge`, `--force`, and `--plan`. +State receipts keep three facts separate. The installed MCP documents determine each server's +runtime location (including relative overrides resolved from that server's execution directory); +the receipt records the locations observed for this installation; and only independent ownership +evidence authorizes deletion. The default +`~/.agent-bundle/state/-` (or +`$XDG_STATE_HOME/agent-bundle/-`) namespace is owned by construction. An explicit +`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. + ## doctor | Option | Default | Meaning | @@ -260,8 +274,9 @@ surface exposes it (`AB7330`). It inventories the Agent Bundle receipt store und and warns about receipts the host no longer honours (`AB7328`), and reports receipts written before format 2 as migrated (`AB7329`). A Cursor directory holding only preserved runtime state from `uninstall --keep-data` is reported `missing` with an `AB7307` info, not corrupt or foreign. -For every installed copy Doctor reports the resolved framework state root, its `native` or -`derived` source, whether it exists, and whether it is writable. A pre-#640 +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`. ## validate diff --git a/website/docs/zh/guide/distribution/installation.mdx b/website/docs/zh/guide/distribution/installation.mdx index c428d6afc..6aac69566 100644 --- a/website/docs/zh/guide/distribution/installation.mdx +++ b/website/docs/zh/guide/distribution/installation.mdx @@ -89,8 +89,9 @@ node ./install.mjs ——不是本插件安装器放置的——无论如何都会被拒绝。Cursor 副本携带安装回执(`.agent-bundle-install.json`: 插件、版本、宿主、内容哈希、归属文件);替换就地进行,只触碰归属文件,绝不动旧版或就地的 `state/` 之类的非归属条目, `--replace` 会接管回执出现之前的副本。本发行版构建的产物把框架状态放在 -`~/.agent-bundle/state/-`(`AGENT_BUNDLE_STATE_ROOT` 覆盖该位置),`uninstall` -配合 `--purge-data --confirm-purge` 会按已安装代码根删除它。Claude 的替换先运行 `claude plugin uninstall --keep-data` 再重新安装, +`~/.agent-bundle/state/-`(`AGENT_BUNDLE_STATE_ROOT` 覆盖该位置)。`uninstall` +配合 `--purge-data --confirm-purge` 只会删除回执证明归该安装独占的根;预先存在、共享、无标记或其他 +无法证明归属的覆盖根都会保留。Claude 的替换先运行 `claude plugin uninstall --keep-data` 再重新安装, 因为 `plugin update` 受版本门控;Codex 先 `codex plugin remove` 再 `add`。输出的 `INSTALL.md` 按宿主记录了 同样的步骤。 diff --git a/website/docs/zh/reference/cli.mdx b/website/docs/zh/reference/cli.mdx index fdaf0b72c..133856b8b 100644 --- a/website/docs/zh/reference/cli.mdx +++ b/website/docs/zh/reference/cli.mdx @@ -185,6 +185,8 @@ agent-bundle install [--from ] [--scope ] [--mode ] \ 每次安装都会写入生命周期回执(格式 `agent-bundle-install-receipt/2`:版本、内容哈希、模式、作用域、归属路径、 宿主注册、时间戳)——Cursor 本地副本写在树内,Claude、Codex 与 Cursor 市场模式写在 `<宿主根目录>/agent-bundle/receipts/` 下——`uninstall` 与 `doctor` 都消费它。 +安装期间,尚不存在的显式状态根会由安装器创建,并写入记录安装身份的 +`.agent-bundle-state-owner.json`。为到达该根而创建的父目录不会被声明为归属内容,也绝不会被递归删除。 ## uninstall @@ -199,8 +201,8 @@ agent-bundle uninstall [--from ] [--scope ] [--mode ] | `--from ` | `process.cwd()` | 其 `agent-bundle.manifest.json` 用于识别插件(名称、版本、市场)的组合根目录,读取方式与 `install` 完全相同(相同条件下为 `AB7001`)。 | | `--scope ` | `user` | 安装时使用的作用域(Claude)。 | | `--mode ` | `local` | 仅限 Cursor:卸载 `local` 副本或已暂存的 `marketplace` 仓库。 | -| `--keep-data` | 开启 | 保留有效框架状态根(`AGENT_BUNDLE_STATE_ROOT`,否则为 `~/.agent-bundle/state/-` 或 `$XDG_STATE_HOME/agent-bundle/-`)、推导出的 web-data、旧版 `state/`,以及回执记录的 Cursor `PLUGIN_DATA` 目录。这是默认行为;该标志只是显式声明。 | -| `--purge-data` | 关闭 | 删除与该已安装代码根精确对应的上述持久数据根。没有 `--confirm-purge` 时被拒绝(`AB7008`)。 | +| `--keep-data` | 开启 | 保留回执记录的所有框架状态根、推导出的 web-data、旧版 `state/`,以及回执记录的 Cursor `PLUGIN_DATA` 目录。这是默认行为;该标志只是显式声明,并保留归属回执供以后 purge。 | +| `--purge-data` | 关闭 | 只删除回执记录且由该安装独占的持久数据根。没有 `--confirm-purge` 时被拒绝(`AB7008`);共享、外部管理、无标记、外来标记及其他无法证明归属的根都会被保留并列出。 | | `--force` | 关闭 | 在没有回执(旧版 Cursor 副本、仅宿主侧的安装)或归属内容、版本、暂存 `HEAD` 与回执不再匹配时继续。回执或清单指向另一个插件时无论如何都会被拒绝。 | | `--plan` | 关闭 | 打印将被删除的确切路径与宿主注册,不做任何改动。 | @@ -216,6 +218,13 @@ Claude 为 `retained-by-host`(缓存副本在 Claude 约 14 天的宽限期内 而 `codex plugin remove` 会删除缓存树。相对包的安装器 bin 接受带同样标志的 `uninstall `;输出的 `install.mjs` 接受 `--uninstall`,并支持 `--mode`、`--keep-data`、`--purge-data --confirm-purge`、`--force` 与 `--plan`。 +状态回执把三件事分开:已安装的 MCP 文档决定每个服务器的运行时位置(相对覆盖值从该服务器的执行目录解析); +回执记录本次安装观察到的位置;只有独立的归属证据才允许删除。默认的 +`~/.agent-bundle/state/-`(或 +`$XDG_STATE_HOME/agent-bundle/-`)命名空间按构造归该安装独占。显式 +`AGENT_BUNDLE_STATE_ROOT` 只有在安装时原本不存在、由安装器创建并写入安装身份标记时才归该安装所有。 +预先存在的目录绝不会仅因服务器声明或卸载时的当前环境指向它而被递归删除。多个服务器与多个根会分别记录、分别判断。 + ## doctor | 选项 | 默认值 | 含义 | @@ -240,7 +249,8 @@ Claude 为 `retained-by-host`(缓存副本在 Claude 约 14 天的宽限期内 的原因(`AB7330`)。它还清点每个宿主根目录下的 Agent Bundle 回执仓库,对宿主已不再认可的回执发出警告(`AB7328`),并把 格式 2 之前写入的回执报告为已迁移(`AB7329`)。仅包含 `uninstall --keep-data` 所保留运行时状态的 Cursor 目录会以 `AB7307` info 报告为 `missing`,而不是 corrupt 或 foreign。 -对于每份已安装副本,Doctor 会报告解析后的框架状态根、其 `native` 或 `derived` 来源、是否存在以及是否可写。 +对于每份已安装副本,Doctor 会报告每个服务器对应的框架状态根、其 `native` 或 `derived` 来源、回执归属 +(`derived`、`marker`、`unowned` 或 `unrecorded`)、当前证据是否允许 purge、使用它的服务器、是否存在以及是否可写。 升级 #640 之前留下的 `/state` 会单独报告,并以 `AB7332` 标记。 ## validate