diff --git a/.changeset/refuse-unreceipted-purge.md b/.changeset/refuse-unreceipted-purge.md new file mode 100644 index 000000000..19701776a --- /dev/null +++ b/.changeset/refuse-unreceipted-purge.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": minor +--- + +Refuse `agent-bundle uninstall claude|codex --force --purge-data --confirm-purge` with `AB7009` when no store receipt proves the bundle owns the install. Without a receipt, web-data and Claude's `plugins/data//` are no longer deleted. The refusal happens before any host verb runs, and `--plan` refuses the same way. `--force` without `--purge-data` still uninstalls through the host CLI and keeps the data. Remove unreceipted data by hand. (#853) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 5d4bc8771..c4035bf66 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -1506,6 +1506,11 @@ directory) is kept by default; `--keep-data` says so explicitly. `--purge-data` removes it only with `--confirm-purge`, and only the roots whose recorded ownership is currently provable; a root the receipt lists as `unowned`, or a receipt with no `state` block at all, retains the observed root as `unproven`. +A host-registered Claude/Codex copy with no store receipt is never purged: +`--force --purge-data --confirm-purge` is refused with `AB7009` before any host +verb runs, since nothing proves the bundle owns its web-data or Claude's +`plugins/data//`; `--force` without `--purge-data` still uninstalls it +through the host verbs and keeps that data. The typed `data.outcome` is honest per host: `kept` / `purged` / `absent` (Cursor local, Agent Bundle's own doing), `retained-by-host` (Claude 2.1.257 orphans the cached copy for its ~14-day grace period; a purge additionally @@ -1540,7 +1545,7 @@ exhausted (`AB7307`) instead of claiming preserved state that is gone. | --- | --- | --- | --- | | `AB7007` | error | `uninstall` refused a mismatch or a foreign target: the owned files hash differently from the receipt, the cached host copy differs from the receipt in version or content, the staged repository's `HEAD` is not the recorded commit or its working tree is dirty / unverifiable, the receipt names another plugin, the directory is not this plugin's install at all, or a destination entry the receipt owns is a symlink or special file. | `--force` overrides content and `HEAD` mismatches (the receipt-owned set is still the only thing removed); a receipt or manifest naming another plugin, and symlinked entries, are refused regardless, inspect and remove them manually. | | `AB7008` | error | `--purge-data` without `--confirm-purge`, `--purge-data` together with `--keep-data`, or (Claude) `--purge-data` while the same plugin is installed at another scope or in another project (a live `plugin list --json` row, an entry in Claude's `plugins/installed_plugins.json` registry, or a stored receipt for the same plugin), the cached copy and `plugins/data//` are scope-less and still in use, or while `claude plugin list --json` or that registry cannot be read to prove there is no other scope. | Pass `--purge-data --confirm-purge` to delete durable state, or neither flag to keep it; for a shared Claude scope, uninstall without `--purge-data` and purge after the last scope is removed. | -| `AB7009` | error | `uninstall` found a host-registered install but no store receipt proving Agent Bundle made it: a staged Cursor marketplace repository without its store receipt, or a host-registered Claude/Codex copy without its store receipt. A Cursor local copy without a receipt is foreign (`AB7007`), never `AB7009`. | Re-run with `--force` (the staged repository is removed wholesale; a host-CLI install is removed through the host verbs), or reinstall with `--replace` first to record a receipt. | +| `AB7009` | error | `uninstall` found a host-registered install but no store receipt proving Agent Bundle made it: a staged Cursor marketplace repository without its store receipt, or a host-registered Claude/Codex copy without its store receipt. With `--force`, the same missing Claude/Codex receipt still refuses `--purge-data --confirm-purge` (without `--confirm-purge` it is `AB7008` first): the uninstall is refused before any host verb runs, because no receipt proves the bundle owns the web-data or `plugins/data//`. A Cursor local copy without a receipt is foreign (`AB7007`), never `AB7009`. | Re-run with `--force` (the staged repository is removed wholesale; a host-CLI install is removed through the host verbs, and its data is kept), or reinstall with `--replace` first to record a receipt. Without a receipt, drop `--purge-data` and remove the data by hand if it is yours. | The Cursor and portable host-install proofs (`tests/host-install-proof.test.ts`, `tests/packed-host-install-proof.test.ts`) snapshot the isolated home before diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index af5b1791f..1c782ebd8 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -107,8 +107,9 @@ const claudeInstructions = (model: NormalizedPlugin): string[] => [ 'a scoped install), and `uninstall` consumes it, running the two commands above in order and retaining the', 'marketplace while any other plugin, scope, or project still installs from it. Durable runtime state', 'is kept by default; `--purge-data --confirm-purge` removes the receipt-owned state roots and', - '`~/.claude/plugins/data//` immediately. A missing receipt or a cached copy that no longer matches it is refused unless `--force`; a', - 'second run is a `not-installed` no-op.', + '`~/.claude/plugins/data//` immediately. A missing receipt or a cached copy that no longer matches it is refused unless `--force`;', + '`--purge-data --confirm-purge` without a receipt is refused even with `--force` (`AB7009`); a second run is a', + '`not-installed` no-op.', '', ]; @@ -154,7 +155,8 @@ const codexInstructions = (model: NormalizedPlugin): string[] => [ 'under `$CODEX_HOME`), and `uninstall` consumes it, running the two commands above in order. `--keep-data`', 'keeps the framework state roots the receipt records outside the cached tree (`kept`); with nothing there the', 'result says so (`unavailable`). A missing receipt or a cached', - 'copy that no longer matches it is refused unless `--force`; a second run is a `not-installed` no-op.', + 'copy that no longer matches it is refused unless `--force`; `--purge-data --confirm-purge` without a receipt is', + 'refused even with `--force` (`AB7009`); a second run is a `not-installed` no-op.', '', ]; diff --git a/packages/agent-bundle/src/install/uninstall.ts b/packages/agent-bundle/src/install/uninstall.ts index f0dd31d6b..af7984dc0 100644 --- a/packages/agent-bundle/src/install/uninstall.ts +++ b/packages/agent-bundle/src/install/uninstall.ts @@ -185,7 +185,8 @@ export interface UninstallBundleOptions { /** * Proceed when a host-registered copy has no store receipt, or when owned * content no longer matches the receipt. Directories without a receipt - * naming this plugin are foreign and refused regardless. + * naming this plugin are foreign and refused regardless, and a copy without + * a store receipt is never purged: `purgeData` is refused (`AB7009`). */ readonly force?: boolean; readonly from: string; @@ -1277,6 +1278,16 @@ const uninstallPublicCli = async ( host, ); } + if (policy === 'purge') { + throw failure( + 'AB7009', + `Refusing --purge-data for ${id} on ${host}: no agent-bundle receipt exists at ${receiptPath}, so nothing ` + + `proves this bundle owns its web-data${host === 'claude' ? ` or ${join(hostRoot, 'plugins', 'data', id)}` : ''}, ` + + 'and --force does not extend to durable data. Re-run with --force and without --purge-data (the data is ' + + 'kept), then remove the data by hand if it is yours.', + host, + ); + } } else if (receipt.plugin !== identity.plugin) { throw failure('AB7007', `Refusing to uninstall: the receipt at ${receiptPath} names plugin ${JSON.stringify(receipt.plugin)}.`, host); } else { diff --git a/packages/agent-bundle/tests/support/host-install.ts b/packages/agent-bundle/tests/support/host-install.ts index b79a656bc..15f287f31 100644 --- a/packages/agent-bundle/tests/support/host-install.ts +++ b/packages/agent-bundle/tests/support/host-install.ts @@ -2956,6 +2956,12 @@ export const runHostUninstallProof = async ( await access(join(installedRoot, 'INSTALL.md')).catch(() => fail('Cursor refused a foreign uninstall but removed files anyway.')); await removeTree(installedRoot); } else { + // --force does not extend to durable data: without a store receipt a purge is refused before any host verb runs. + await expectRefusal( + lifecycle('uninstall', ['--force', '--purge-data', '--confirm-purge']), + 'AB7009', + `${host} uninstall --force --purge-data without a receipt`, + ); // A host-CLI install with no store receipt is `forced-missing`: --force removes what the host lists as this plugin's. const forced = await uninstall(['--force']); assertProof( diff --git a/packages/agent-bundle/tests/uninstall.test.ts b/packages/agent-bundle/tests/uninstall.test.ts index 2b1864e63..7e0ed7636 100644 --- a/packages/agent-bundle/tests/uninstall.test.ts +++ b/packages/agent-bundle/tests/uninstall.test.ts @@ -1,5 +1,5 @@ import { execFile } from 'node:child_process'; -import { cp, mkdir, mkdtemp, readFile, readdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { access, cp, mkdir, mkdtemp, readFile, readdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -17,7 +17,7 @@ import { readInstallReceipt, readInstallReceiptFile, } from '../src/install/receipt.ts'; -import { recordInstalledState } from '../src/install/state-root.ts'; +import { installedWebDataRoot, 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'; @@ -1278,9 +1278,26 @@ it.each([ const missing = await failureOf(uninstallBundle(options)); expect(missing.diagnostics[0]).toMatchObject({ code: 'AB7009', target: host }); expect(calls.map((call) => call.args.join(' '))).toEqual(['plugin list --json']); + // --force never extends to durable data: without a receipt nothing proves the web-data or Claude's plugins/data + // are this bundle's, so a forced purge is refused before any host verb runs and the data survives. + const unprovenData = [ + installedWebDataRoot(installPath, fixture.home), + ...(host === 'claude' ? [join(hostRoot, 'plugins', 'data', 'uninstall-fixture@uninstall-fixture-marketplace')] : []), + ]; + for (const path of unprovenData) await writeJson(join(path, 'data.json'), { owner: 'unknown' }); + calls.length = 0; + const forcedPurge = await failureOf(uninstallBundle({ ...options, confirmPurge: true, force: true, purgeData: true })); + expect(forcedPurge.diagnostics[0]).toMatchObject({ code: 'AB7009', target: host }); + expect(forcedPurge.diagnostics[0]?.message).toContain('Refusing --purge-data'); + expect((await failureOf(uninstallBundle({ ...options, confirmPurge: true, force: true, plan: true, purgeData: true }))).diagnostics[0]) + .toMatchObject({ code: 'AB7009' }); + expect(calls.map((call) => call.args.join(' '))).toEqual(['plugin list --json', 'plugin list --json']); + for (const path of unprovenData) await access(join(path, 'data.json')); calls.length = 0; const forced = await uninstallBundle({ ...options, force: true }); expect(forced).toMatchObject({ forced: true, receipt: { status: 'forced-missing' }, state: 'uninstalled' }); + for (const path of unprovenData) await access(join(path, 'data.json')); + for (const path of unprovenData) await removeTree(path); // Without a receipt nothing proves Agent Bundle registered the marketplace, so --force removes the plugin only // and retains the marketplace, naming the verb to run by hand. expect(forced.registrations).toEqual([ diff --git a/website/docs/en/guide/distribution/installation.mdx b/website/docs/en/guide/distribution/installation.mdx index 94f89dd8a..a10ad5472 100644 --- a/website/docs/en/guide/distribution/installation.mdx +++ b/website/docs/en/guide/distribution/installation.mdx @@ -267,7 +267,9 @@ pack the `PLUGIN_DATA` directory the receipt records are kept unless you pass `--purge-data --confirm-purge`, and the result states honestly what the host itself decided where Agent Bundle cannot (Claude orphans its cached copy for a ~14-day grace period; Codex deletes the cached tree and offers no keep-data option). A missing store receipt for a host-registered install -(`AB7009`) or a content mismatch (`AB7007`) is refused unless `--force`; a Cursor directory without +(`AB7009`) or a content mismatch (`AB7007`) is refused unless `--force`. Even with `--force`, a +copy without a store receipt is never purged: `--purge-data --confirm-purge` is refused (`AB7009`) because nothing +proves the bundle owns its data, so uninstall without it and remove the data by hand. A Cursor directory without a receipt, or one that belongs to another plugin, is foreign and refused regardless (`AB7007`); a second run is a `not-installed` no-op. Only format 2 receipts are read; a copy carrying an older receipt is foreign. When a receipt records no state ownership, a root resolved from the current diff --git a/website/docs/en/reference/cli.mdx b/website/docs/en/reference/cli.mdx index 3547c7662..3814b8552 100644 --- a/website/docs/en/reference/cli.mdx +++ b/website/docs/en/reference/cli.mdx @@ -228,7 +228,7 @@ agent-bundle uninstall [--from ] [--scope ] [--mode ] | `--mode ` | `local` | Cursor only: uninstall the `local` copy or the staged `marketplace` repository. | | `--keep-data` | on | Keep every recorded framework state root, derived web-data, 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 store receipt for a host-registered install (Claude/Codex, staged Cursor marketplace) or when owned content, version, or staged `HEAD` no longer matches the receipt. A Cursor local directory without a receipt, or a receipt or manifest naming another plugin, is foreign and refused regardless (`AB7007`). | +| `--force` | off | Proceed without a store receipt for a host-registered install (Claude/Codex, staged Cursor marketplace) or when owned content, version, or staged `HEAD` no longer matches the receipt. A Claude/Codex copy without a store receipt keeps its data: `--force --purge-data --confirm-purge` is refused (`AB7009`). A Cursor local directory without a receipt, or a receipt or manifest naming another plugin, is foreign and refused regardless (`AB7007`). | | `--plan` | off | Print the exact paths and host registrations that would be removed and change nothing. | Uninstall removes exactly what the receipt owns: the recorded files and installer-created diff --git a/website/docs/zh/guide/distribution/installation.mdx b/website/docs/zh/guide/distribution/installation.mdx index b7343b0e0..337582e1d 100644 --- a/website/docs/zh/guide/distribution/installation.mdx +++ b/website/docs/zh/guide/distribution/installation.mdx @@ -211,7 +211,8 @@ node artifact/install.mjs --uninstall [--mode marketplace] 归属的内容,只撤销它记录的注册,绝不多删;非归属条目会被列为保留,插件旁的 `state/` 目录也在其中。回执带归属证据记录的框架状态根、推导出的 web-data,以及 Agent Plugins 包的 Cursor 副本在回执中记录的 `PLUGIN_DATA` 目录,除非传入 `--purge-data --confirm-purge` 否则保留,且结果如实说明宿主自行决定而 Agent Bundle 无法左右的部分(Claude 把缓存 副本标为 orphaned 并保留约 14 天;Codex 删除缓存树且没有 keep-data 选项)。宿主已注册的安装缺少仓库回执(`AB7009`)或内容不匹配 -(`AB7007`)会被拒绝,除非 `--force`;没有回执的 Cursor 目录,或属于另一个插件的目录,是外来目录,无论如何都被拒绝(`AB7007`); +(`AB7007`)会被拒绝,除非 `--force`。即使带 `--force`,缺少仓库回执的副本也绝不会被 purge: +`--purge-data --confirm-purge` 被拒绝(`AB7009`),因为没有任何证据表明该 bundle 拥有这些数据,请不带它卸载,再手动删除数据。没有回执的 Cursor 目录,或属于另一个插件的目录,是外来目录,无论如何都被拒绝(`AB7007`); 再次运行是 `not-installed` 空操作。只读取格式 2 的回执;携带更早回执的副本是外来目录。如果回执未记录状态归属, 从当前环境或 home 解析出的根只会列为无法证明并保留;`--keep-data` 也不能把这次观察变成以后 purge 的权限。 diff --git a/website/docs/zh/reference/cli.mdx b/website/docs/zh/reference/cli.mdx index 9544e4d24..650ea0f53 100644 --- a/website/docs/zh/reference/cli.mdx +++ b/website/docs/zh/reference/cli.mdx @@ -212,7 +212,7 @@ agent-bundle uninstall [--from ] [--scope ] [--mode ] | `--mode ` | `local` | 仅限 Cursor:卸载 `local` 副本或已暂存的 `marketplace` 仓库。 | | `--keep-data` | 开启 | 保留回执记录的所有框架状态根、推导出的 web-data,以及回执记录的 Cursor `PLUGIN_DATA` 目录。这是默认行为;该标志只是显式声明,并保留归属回执供以后 purge。 | | `--purge-data` | 关闭 | 只删除回执记录且由该安装独占的持久数据根。没有 `--confirm-purge` 时被拒绝(`AB7008`);共享、外部管理、无标记、外来标记及其他无法证明归属的根都会被保留并列出。 | -| `--force` | 关闭 | 在宿主已注册的安装(Claude/Codex、暂存的 Cursor 市场)缺少仓库回执,或归属内容、版本、暂存 `HEAD` 与回执不再匹配时继续。没有回执的 Cursor 本地目录,以及回执或清单指向另一个插件的目录,是外来目录,无论如何都会被拒绝(`AB7007`)。 | +| `--force` | 关闭 | 在宿主已注册的安装(Claude/Codex、暂存的 Cursor 市场)缺少仓库回执,或归属内容、版本、暂存 `HEAD` 与回执不再匹配时继续。缺少仓库回执的 Claude/Codex 副本会保留其数据:`--force --purge-data --confirm-purge` 被拒绝(`AB7009`)。没有回执的 Cursor 本地目录,以及回执或清单指向另一个插件的目录,是外来目录,无论如何都会被拒绝(`AB7007`)。 | | `--plan` | 关闭 | 打印将被删除的确切路径与宿主注册,不做任何改动。 | uninstall 只删除回执归属的内容:记录的文件与安装器创建的目录(Cursor 本地,包括安装本身创建的