Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/refuse-unreceipted-purge.md
Original file line number Diff line number Diff line change
@@ -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/<id>/` 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)
7 changes: 6 additions & 1 deletion docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>/`; `--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
Expand Down Expand Up @@ -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/<id>/` 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/<id>/`. 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
Expand Down
8 changes: 5 additions & 3 deletions packages/agent-bundle/src/install/surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>/` 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/<id>/` 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.',
'',
];

Expand Down Expand Up @@ -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.',
'',
];

Expand Down
13 changes: 12 additions & 1 deletion packages/agent-bundle/src/install/uninstall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 ` +
Comment thread
ScriptedAlchemy marked this conversation as resolved.
`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 {
Expand Down
6 changes: 6 additions & 0 deletions packages/agent-bundle/tests/support/host-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
21 changes: 19 additions & 2 deletions packages/agent-bundle/tests/uninstall.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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';
Expand Down Expand Up @@ -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([
Expand Down
4 changes: 3 additions & 1 deletion website/docs/en/guide/distribution/installation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion website/docs/en/reference/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ agent-bundle uninstall <host> [--from <root>] [--scope <scope>] [--mode <mode>]
| `--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
Expand Down
3 changes: 2 additions & 1 deletion website/docs/zh/guide/distribution/installation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 的权限。

Expand Down
2 changes: 1 addition & 1 deletion website/docs/zh/reference/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ agent-bundle uninstall <host> [--from <root>] [--scope <scope>] [--mode <mode>]
| `--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 本地,包括安装本身创建的
Expand Down
Loading