diff --git a/.changeset/foreign-destination-inventory.md b/.changeset/foreign-destination-inventory.md index 8d0c26b94..98396055f 100644 --- a/.changeset/foreign-destination-inventory.md +++ b/.changeset/foreign-destination-inventory.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Refuse a foreign destination and a same-version marketplace restage from `install.mjs` when the destination lacks paths listed in `agent-bundle.manifest.json`, instead of crashing with `ENOENT`. `uninstall --force` on a pre-receipt copy removes the files present in that copy, matching the framework CLI. (#818) +Refuse a foreign destination and a same-version marketplace restage from `install.mjs` when the destination lacks paths listed in `agent-bundle.manifest.json`, instead of crashing with `ENOENT`. (#818) diff --git a/.changeset/remove-legacy-install-state.md b/.changeset/remove-legacy-install-state.md new file mode 100644 index 000000000..c2a75ba7e --- /dev/null +++ b/.changeset/remove-legacy-install-state.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": minor +--- + +Remove the legacy install readers from `install`, `uninstall`, `doctor`, and the emitted `install.mjs`: format-1 receipts (`agent-bundle-install-receipt/1`), receipt-less "legacy" adoption of a pre-receipt Cursor copy, the in-tree `/state` handling and its `--purge-data` removal, the compatibility receipt `stateRoot` field, and the `AB7317` unsupported-runtime report. A Cursor directory without a format-2 receipt naming the plugin is foreign: `install` refuses it with `AB7005` (with or without `--replace`), `uninstall` refuses it with `AB7007` (with or without `--force`), and Doctor reports it as `AB7321`; remove such a directory by hand and reinstall. An in-tree `state/` is an ordinary unowned entry that `uninstall` retains and lists, never purges. A runtime that rejects the status probe is a failed probe (`AB7318`). `AB7317`, `AB7329`, and `AB7332` are retired. (#841) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 9d7d82af2..70aafc2e1 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -34,11 +34,11 @@ even when no error diagnostic was reported. | `AB5000` | General CLI and adapter failures (see below). | | `AB60xx` | Built-artifact validation, including schema documents and referenced files (`AB6005`: the compiler finds a host-pack surface or package-build entry (`dist/bin/*.js`, the Flight workers, or the `lib` entry) that keeps something other than a Node built-in, `pnpapi`, or an emitted sibling external, or an MCP App view that keeps anything external; the emitted-module walk remains only for what the compiler cannot see, an expression `import()` in a compiled module, and the imports and syntax of JavaScript the framework did not compile or a `tools` hatch may have rewritten; a `dist` finding names `dist/`; `AB6011`/`AB6012`: a target's required pinned-schema document is missing or invalid; `AB6025`: a manifest-declared `logo` path is missing from the artifact or escapes the deploy tree; `AB6034`: emitted Skill Markdown has no instruction body; `AB6035`–`AB6038`: Agent Plugins portable validation, see below). | | `AB6200`–`AB6202` | Workbench artifact inspection over published epochs: `AB6200` the validator threw or an internal post-validation invariant failed, `AB6201` an epoch reference could not be released, `AB6202` unsafe runtime metadata. Artifact-validation diagnostics such as `AB6001` retain their original codes (see below). | -| `AB700x` | Host installation and uninstallation: bundle identity, host availability, scope, command failure, and collision checks (`AB7000`–`AB7004`: unsupported host, unreadable bundle identity, missing host, scope or mode refusal, host command failure, the same five codes are also the development project service's preparation failures; `AB7001` in detail: the composite root at `--from` cannot be resolved for the host from its `agent-bundle.manifest.json`, the manifest is missing or not canonical, has no `projections[]` row for the host, the row has no host plugin manifest pointer or the pointed file is missing, a `files[]` row is missing or its bytes, size, digest, or executable state differ from the row after npm normalization, `claude`/`codex` have no marketplace identity, or the `cursor` plugin name is not a safe local plugin name; `install`, `uninstall`, and `doctor` never probe `.claude-plugin/plugin.json` or look under `/`; `AB7005`: version collision, pre-receipt content collision, or foreign install; `AB7006`: the host lists the installed copy with load errors; see below), plus the `uninstall` refusals `AB7007`–`AB7009` (ownership or content mismatch, unconfirmed data purge, missing receipt; see below). | +| `AB700x` | Host installation and uninstallation: bundle identity, host availability, scope, command failure, and collision checks (`AB7000`–`AB7004`: unsupported host, unreadable bundle identity, missing host, scope or mode refusal, host command failure, the same five codes are also the development project service's preparation failures; `AB7001` in detail: the composite root at `--from` cannot be resolved for the host from its `agent-bundle.manifest.json`, the manifest is missing or not canonical, has no `projections[]` row for the host, the row has no host plugin manifest pointer or the pointed file is missing, a `files[]` row is missing or its bytes, size, digest, or executable state differ from the row after npm normalization, `claude`/`codex` have no marketplace identity, or the `cursor` plugin name is not a safe local plugin name; `install`, `uninstall`, and `doctor` never probe `.claude-plugin/plugin.json` or look under `/`; `AB7005`: version collision or foreign install; `AB7006`: the host lists the installed copy with load errors; see below), plus the `uninstall` refusals `AB7007`–`AB7009` (ownership or content mismatch, unconfirmed data purge, missing receipt; see below). | | `AB7010`–`AB7015` | npm prepack inventory, artifact freshness, package bin targets, release-version agreement, and installed-dependency hygiene (`AB7014`: a dependency no consumer-runtime evidence requires; `AB7015`: a git, remote-tarball, path, or unrewritten workspace-protocol dependency specifier). | | `AB7200`–`AB7202`, `AB7210`–`AB7211` | Development rebuilds and live host surfaces: rebuild admission and phase failures, development host install sync, and the dev-epoch contract gate (see below). | | `AB7xxx` | Project preparation and development rebuilds (`AB7100`–`AB7102`: a development rebuild's compilation, publication, and cleanup; `AB7101` is also the one-shot `build` / `build()` refusal when source changes during compilation; `AB7103`: the development package build; see below). | -| `AB7300`–`AB7333` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, runtime endpoint health and identity, durable-state inventory, static bytes-at-rest validation, foreign-install detection (`AB7321`; see below), Cursor plugin hook registration / marketplace staging (`AB7322`–`AB7324`; see below), host load refusal (`AB7325`; see below), the Cursor Agent Plugins launch proof (`AB7326`; see below), a disabled Claude install (`AB7327`; see below), lifecycle receipts and activation states (`AB7328`–`AB7330`; see below), the operator `.env` layer of an installed pack (`AB7331`; see below), retained pre-#640 state (`AB7332`; see below), and dangling receipt-owned marketplaces (`AB7333`; see below). `AB7311` and `AB7325` are also emitted by `build` and `validate --artifact` from the Claude load check (see "Claude Code host validation"). | +| `AB7300`–`AB7333` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, runtime endpoint health and identity, durable-state inventory, static bytes-at-rest validation, foreign-install detection (`AB7321`; see below), Cursor plugin hook registration / marketplace staging (`AB7322`–`AB7324`; see below), host load refusal (`AB7325`; see below), the Cursor Agent Plugins launch proof (`AB7326`; see below), a disabled Claude install (`AB7327`; see below), lifecycle receipts and activation states (`AB7328`–`AB7330`; see below), the operator `.env` layer of an installed pack (`AB7331`; see below), the retired `AB7332` (see below), and dangling receipt-owned marketplaces (`AB7333`; see below). `AB7311` and `AB7325` are also emitted by `build` and `validate --artifact` from the Claude load check (see "Claude Code host validation"). | | `AB8200`–`AB8209` | Workbench development runtime routes (`/api/runtime/**`): `AB8200` development runtime provider configuration, load, or lifecycle failure, `AB8201` runtime/session/run not available, `AB8202` invalid route path, `AB8203` invalid request shape, `AB8204` stale runtime generation or MCP session revision (409), `AB8205` runtime request could not be completed, `AB8206` Workbench runtime client failure, `AB8207` Agent Document decoding needs the optional `@agent-bundle/runtime` peer (503), `AB8208` stored Flight could not be decoded as an Agent Document (409), `AB8209` decoded Agent Document over the 16 MiB budget (413) or an invalid document response. | | `AB8210`–`AB8214` | Workbench semantic lifecycle replay routes (`/api/lifecycles`, `/api/lifecycles/replays`): `AB8210` invalid path, `AB8211` malformed replay request or native envelope (400, carries the shared validator message), `AB8212` replay unavailable or could not be completed, `AB8213` stale manifest binding (409; the page repairs it with refresh → explicit re-run), `AB8214` replay over the 16 MiB budget (413). | | `AB8215`–`AB8218` | Workbench read-only host discovery route (`/api/discovery`): `AB8215` invalid path, `AB8216` query string or non-`GET` method (400/405), `AB8217` report over the 16 MiB response limit (413), `AB8218` discovery not available (503). | @@ -1219,7 +1219,7 @@ SQLite lock or shared-memory files. | Code | Severity | Trigger | | --- | --- | --- | -| `AB7316` | warning | An installed bundle's effective or legacy state directory is not writable, or the directory or one of its `*.sqlite`, `-wal`, or `-shm` files cannot be read with filesystem metadata operations. Repair permissions and rerun Doctor; Doctor never repairs state. | +| `AB7316` | warning | An installed bundle's effective state directory is not writable, or the directory or one of its `*.sqlite`, `-wal`, or `-shm` files cannot be read with filesystem metadata operations. Repair permissions and rerun Doctor; Doctor never repairs state. | ## Read-only Doctor operator env inventory (`AB7331`) @@ -1234,23 +1234,22 @@ diagnostic. | --- | --- | --- | | `AB7331` | info / warning | Info: an installed copy (or the `--from` bundle) carries `.env` or `.env.local` at its plugin root; the message names the file and its variable count. Warning: the file exists but cannot be read, so the pack's shells skip it at launch, repair its permissions and rerun Doctor. | -## Read-only Doctor legacy state (`AB7332`) +## Read-only Doctor framework state roots (`AB7332` retired) 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. In particular, a supported legacy receipt with no recorded state -location cannot turn the current environment or home into purge authority; -Doctor reports that observed root as unrecorded and retained. When a -compatibility receipt does record `stateRoot`, Doctor lists that historical -root separately if the current environment resolves elsewhere and marks only -the receipt-recorded derived root purgeable. +writability. A runtime location without matching receipt ownership remains +visible but is never deletion authority. In particular, a receipt with no +`state` block (written before the install could record ownership) cannot turn +the current environment or home into purge authority; Doctor reports that +observed root as unrecorded and retained. A `state/` directory beside the +plugin is an ordinary unowned entry: it is never inventoried as durable +state, never purged, and is listed by `uninstall` as retained. | Code | Severity | Trigger | | --- | --- | --- | -| `AB7332` | info | `/state` still exists while the installed artifact resolves framework state elsewhere. Move any state that must be retained, or use `uninstall --purge-data --confirm-purge` to remove the in-tree root plus only those effective roots whose receipt ownership is currently purgeable; unrecorded roots remain retained. | +| `AB7332` | retired | Named an in-tree `/state` directory as legacy durable state. The framework never reads state from the plugin root, and the installer has no authority over that directory; it is reported like any other unowned entry. | ## Read-only Doctor marketplace sources (`AB7333`) @@ -1262,7 +1261,7 @@ the receipt-recorded derived root purgeable. | Code | Severity | Trigger | | --- | --- | --- | -| `AB7317` | info | A live event runtime implements the older strict protocol and does not expose runtime identity. Restart it after upgrading Agent Bundle. | +| `AB7317` | retired | Reported a live event runtime that rejected the status request as `unsupported`. Such a runtime is now a failed probe (`AB7318`, `runtime.status: failed`) carrying the runtime's error message. | | `AB7318` | error | A live event runtime became unavailable, timed out, or returned an invalid status response during the bounded read-only identity probe. Inspect or restart the runtime, then rerun Doctor. | ## Read-only Doctor bundle resolution (`AB7306`) @@ -1343,21 +1342,18 @@ reverses what did complete, the plugin (`plugin uninstall … --keep-data` / `plugin remove`) when it was installed, then the marketplace when this run created it, before rethrowing; a failed reversal is reported with the exact host commands to run before retrying. Nothing is left registered without a -receipt to record it. A format 1 -receipt (written by #420) is read with those -fields synthesized (`mode: local`, `scope: user`, one `cursor-local-plugin` -registration, no host directories) and reported as migrated (`AB7329`); an -identical rerun of the installer rewrites it as format 2. A current-format -receipt missing any field reads as absent, exactly like a malformed one. +receipt to record it. Only `agent-bundle-install-receipt/2` is read: a +format 1 receipt (written by #420), a current-format receipt missing any +field, and a malformed one all read as absent, and the copy they sit in is +foreign. The receipt never participates in the content hash, and neither do empty directories or runtime roots (`state/`): only regular files are plugin content, so the artifact hash, the installed tree, and the receipt always describe the same entries. Ownership of an existing -destination is decided as **receipt** (a receipt naming this plugin), **legacy** -(no receipt, but the emitted `INSTALL.md` + `install.mjs` and a manifest with -this plugin's name, a copy installed before receipts existed), or **foreign** -(anything else). Claude and Codex copies are located through the host's own +destination is decided as **receipt** (a receipt naming this plugin) or +**foreign** (anything else, a copy installed before receipts existed +included). Claude and Codex copies are located through the host's own `plugin list --json` inventory (Doctor runs it once per host and also lists every installed plugin from it; `AB7303` is emitted only when that listing is unusable); the host owns those copies, so replacement runs `claude plugin uninstall @@ -1370,11 +1366,9 @@ settings-preserving update API, and native add would set plugin-level enabled to | Installed copy | `install` | `install --replace` | Doctor | | --- | --- | --- | --- | | Identical content (receipt / host-managed) | `already-installed` no-op | `already-installed` no-op | `current` | -| Identical content (legacy) | `already-installed` no-op | `adopted`, receipt written, no plugin file changes | `current` | | Receipt / host-managed, same version, different content | replaced automatically (`replaced`) | replaced | `stale`, `AB7308` warning | | Receipt / host-managed, different version | `AB7005` version collision | replaced | `version-mismatch`, `AB7309` warning | -| Legacy, different content | `AB7005` content collision | adopted: the artifact's files are rewritten, every other file is left in place and stays unowned, receipt written (`replaced`) | `stale`, `AB7308` warning, recovery names `--replace` | -| Foreign directory | `AB7005` foreign install | `AB7005` foreign install | `foreign`, `AB7321` warning | +| Foreign directory (no receipt naming this plugin, a pre-receipt copy included) | `AB7005` foreign install | `AB7005` foreign install | `foreign`, `AB7321` warning | | Claude copy listed with `errors` (host refused to load it) | identical content: `AB7006`; otherwise replaced, then `AB7006` if the fresh row still carries `errors` | replaced, then `AB7006` if the fresh row still carries `errors` | `load-failed`, `AB7325` error (see below) | | Nothing installed | installed | installed | `not-installed`, `AB7307` info | @@ -1385,7 +1379,7 @@ Cursor replacement is in place and touches owned files only: stale owned files are removed and the emptied directories the installer itself created (`directories` in the receipt) are pruned, staged files are renamed over their predecessors, and the receipt lands last. Entries the installer does not own, -notably workspace-durable `state/` stores, and any directory that already +a `state/` directory beside the plugin included, and any directory that already existed before the installer wrote beneath it, are never removed or rewritten; when a rebuilt artifact introduces a path that an existing unowned entry already occupies, replacement aborts before any change (`AB7004`, "Refusing to overwrite @@ -1408,23 +1402,24 @@ untouched. | Code | Severity | Trigger | Recovery | | --- | --- | --- | --- | -| `AB7005` | error | `install` refused an existing destination: a different installed version without `--replace`, a legacy pre-receipt copy with different content without `--replace`, or a foreign directory (refused even with `--replace`). | Re-run with `--replace` for the first two cases; remove a foreign directory manually. | -| `AB7321` | warning | Doctor found a directory at the Cursor install path that is not an agent-bundle install of this plugin: no receipt naming it and no emitted install surface with a matching manifest, or a receipt naming another plugin. The message carries the installed-versus-artifact content-hash comparison. | Remove the foreign directory manually before installing; `--replace` refuses foreign installs by design. | - -A Cursor destination that holds nothing but preserved runtime state (`state/`, -plus the remnant receipt described below) is what `uninstall --keep-data` -leaves behind: `install` fills it back in as an `installed` (not a -replacement, not a foreign refusal), and Doctor reports it as `missing` with an -`AB7307` info naming the preserved state instead of `AB7321` or `AB7304`. The -remnant receipt alone does not make a directory "state-only": when `uninstall` -also retained unowned entries beside (or instead of) `state/`, both the -inventory finding and the `--from` bundle finding read the directory and the -`AB7307` message names those retained entries and points at removing them by -hand, since `uninstall` never will. Preserved state is only what `uninstall` -would still keep, a `state/` that holds something, and this home's real, -non-empty `PLUGIN_DATA` directory, so a remnant whose data has since been -removed or emptied is reported as exhausted, with the default `uninstall` that -consumes it as the recovery. +| `AB7005` | error | `install` refused an existing destination: a different installed version without `--replace`, or a foreign directory, one without a receipt naming this plugin, refused even with `--replace`. | Re-run with `--replace` for a version collision; remove a foreign directory manually and reinstall. | +| `AB7321` | warning | Doctor found a directory at the Cursor install path that is not an agent-bundle install of this plugin: no readable receipt naming it, or a receipt naming another plugin. The message carries the installed-versus-artifact content-hash comparison. | Remove the foreign directory manually before installing; `--replace` refuses foreign installs by design. | + +A Cursor destination that holds the remnant receipt described below is what +`uninstall --keep-data` leaves behind around retained entries: `install` fills +it back in as an `installed` (not a replacement, not a foreign refusal), and +Doctor reports it as `missing` with an `AB7307` info instead of `AB7321` or +`AB7304`. When `uninstall` retained unowned entries (a `state/` directory +beside the plugin, operator files), both the inventory finding and the `--from` +bundle finding read the directory and the `AB7307` message names those retained +entries and points at removing them by hand, since `uninstall` never will. +Preserved state named by `AB7307` is only this home's real, non-empty +`PLUGIN_DATA` directory, so a remnant whose data has since been removed or +emptied is reported as exhausted, with the default `uninstall` that consumes +it as the recovery. Without the remnant receipt, a directory holding only +`state/` proves nothing: `install` refuses it as foreign (`AB7005`), Doctor +reports the entry as corrupt (`AB7304`) and the destination as foreign +(`AB7321`), and the operator removes it by hand. ## Managed uninstall (`AB7007`–`AB7009`) @@ -1440,7 +1435,8 @@ open issue). Every mutation is opt-in and bounded by the receipt: install created (`~/.cursor/plugins/local`, `~/.cursor/plugins` in a fresh home). Unowned entries are listed as retained and never removed, files by path, and unowned directories that hold nothing retained as `name/` (the - prune only ever touches owned directories, so they survive too). When the + prune only ever touches owned directories, so they survive too); a `state/` + directory beside the plugin is one of them. When the plugin root survives (retained state or unowned entries), a **remnant receipt**, `files: []`, `registrations: []`, the carried `hostDirectories`, is written there so a later purge can still prune the created directories and @@ -1500,32 +1496,37 @@ open issue). Every mutation is opt-in and bounded by the receipt: a receipt orphaned behind Agent Bundle's back is consumed without running any host verb. -Durable runtime state (`state/`: the state kernel and notices journal) is kept -by default; `--keep-data` says so explicitly. `--purge-data` removes it only -with `--confirm-purge`. 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, `state/` included, -for its ~14-day grace period; a purge additionally removes `state/` and -`plugins/data//`), `removed-by-host` (codex-cli 0.147.0 deletes the cached -tree on `plugin remove`), and `unavailable` (Codex has no keep-data option; a -staged Cursor marketplace holds no runtime state). `--plan` computes the same +Durable runtime state (the framework state roots the receipt's `state` block +records with ownership evidence, plus web-data and a recorded `PLUGIN_DATA` +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`. +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 +removes the owned state roots and `plugins/data//`), `removed-by-host` +(codex-cli 0.147.0 deletes the cached tree on `plugin remove`), and +`unavailable` (Codex with no external state to preserve; a staged Cursor +marketplace holds no runtime state). `--plan` computes the same report, exact absolute paths, registrations, data decision, without opening a writer; planned directories are exactly the ones the run would prune (purged -`state/` first, then every owned directory that would be left empty, and for +state roots first, then every owned directory that would be left empty, and for store receipts the `/agent-bundle/receipts` and `/agent-bundle` directories, plus Cursor's `agent-bundle/marketplaces`, once the last entry leaves them), never a directory kept alive by retained state or unowned entries: `removed` in a `--plan` result equals `removed` in the completed one. A second run after a successful uninstall is a `not-installed` no-op. When `--keep-data` left -`state/` (or a written `PLUGIN_DATA` directory) behind under a Cursor local -root, the remnant receipt written there stays in place (`receipt.status: -'remnant'`) and a rerun without `--purge-data` is the same `not-installed` -no-op for as long as that preserved data, or an unowned entry the uninstall -retained, is still there; `--purge-data --confirm-purge` removes the -preserved state and prunes the root. Once the preserved data has been removed -or emptied by hand (an empty `state/` or `PLUGIN_DATA` directory holds no -data, so it is pruned like an installer-created directory rather than kept), +preserved data (a written `PLUGIN_DATA` directory) or retained unowned entries +behind under a Cursor local root, the remnant receipt written there stays in +place (`receipt.status: 'remnant'`) and a rerun without `--purge-data` is the +same `not-installed` no-op for as long as that data or those entries are still +there; `--purge-data --confirm-purge` removes the preserved state and prunes +the root once nothing unowned remains. Once the preserved data has been removed +or emptied by hand (an empty `PLUGIN_DATA` directory holds no data, so it is +pruned like an installer-created directory rather than kept) and the retained +entries are gone, the remnant guards nothing, and the next run, with or without `--purge-data`, consumes it: the receipt, the empty plugin root, and the host and `plugin-data` directories it recorded. Doctor reports such a remnant as @@ -1533,9 +1534,9 @@ exhausted (`AB7307`) instead of claiming preserved state that is gone. | Code | Severity | Trigger | Recovery | | --- | --- | --- | --- | -| `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 / `state/` entry 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. | +| `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 the install but no receipt proving Agent Bundle owns it: a Cursor local copy in the pre-receipt legacy layout, a staged marketplace repository without its store receipt, or a host-registered Claude/Codex copy without its store receipt. | Re-run with `--force` (a legacy Cursor copy is removed by its inventory, `state/` kept; 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. 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. | 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 @@ -1571,7 +1572,7 @@ inventory finding and on the bundle finding). | Code | Severity | Meaning | Recovery | | --- | --- | --- | --- | | `AB7328` | warning | A store receipt is orphaned, the host no longer holds the registration it records (Claude/Codex listing lacks the plugin, a Claude `project`/`local` receipt is checked by `plugin list --json` run from its recorded `projectRoot`, and is `unknown`, never orphaned, when that root cannot be listed; the staged Cursor marketplace repository is gone), or the receipt store / a receipt file could not be read or is not a valid receipt. | `agent-bundle uninstall --from [--mode marketplace]` consumes an orphaned receipt; reinstall to rewrite an invalid one; repair permissions. | -| `AB7329` | info | A receipt predates lifecycle receipts (`agent-bundle-install-receipt/1`) and was read with synthesized `mode`, `scope`, `registrations`, and `hostDirectories`. Doctor never rewrites it. | Rerun `agent-bundle install` (or `install.mjs`) once; an identical copy rewrites the receipt as format 2 without changing plugin files. `uninstall` accepts the migrated receipt as is. | +| `AB7329` | retired | Reported a format 1 receipt (`agent-bundle-install-receipt/1`) read with synthesized lifecycle fields. Format 1 receipts are no longer read; a copy carrying one is foreign (`AB7321`), and Doctor never rewrites the file. | Remove the directory by hand and reinstall. | | `AB7330` | info | The bundle's lifecycle stage on this host and its four observations; the message lists every `unavailable` stage with its reason. When Claude lists the plugin at several scopes the observations aggregate every row, a stage holds only when it holds for every listed copy, and the evidence names the scopes that are disabled, unplaced, or carry no enabled flag, so the report never depends on Claude's row order. | Stage-specific: register (`agent-bundle install`), enable (`claude plugin enable`, Codex `/plugins`, Cursor Customize), or complete the Cursor import; unavailable stages need no action and are never guessed. | ## Live development into hosts (`AB7200`–`AB7202`, `AB7210`–`AB7211`, `AB8024`–`AB8025`) diff --git a/docs/framework-mode.md b/docs/framework-mode.md index fb37b599c..04555fc81 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -622,11 +622,11 @@ manifest. A root whose selection includes `cursor` or `portable` also includes a standalone `install.mjs`. Its staged copy is idempotent for identical content, records an install receipt (`.agent-bundle-install.json`: plugin, version, content hash, owned files and directories), replaces a same-version stale copy of its -own plugin in place (owned files only; legacy `state/` survives, while current builds keep -framework state outside the plugin root), and accepts -`--replace` to replace a different installed version or adopt -a pre-receipt copy. Foreign directories are refused with a content-hash -comparison. It never invokes sudo or changes PATH. `agent-bundle install +own plugin in place (owned files only; unowned entries survive, and framework +state lives outside the plugin root), and accepts +`--replace` to replace a different installed version. A directory without a +receipt naming this plugin, a copy placed before receipts existed included, is +foreign and refused with a content-hash comparison; remove it by hand. It never invokes sudo or changes PATH. `agent-bundle install [--replace]` applies the same policy for every host, and `agent-bundle doctor --from` reports the installed copy versus the artifact as `current`, `stale`, `version-mismatch`, `foreign`, or `not-installed` (see the package README's @@ -644,8 +644,7 @@ host root, the host `registrations` it performed in order, and `installedAt` / `updatedAt`. Cursor local copies carry it in-tree; Claude, Codex, and Cursor marketplace-mode installs keep theirs in `/agent-bundle/receipts/`. `install --replace`, `uninstall`, and `doctor` all consume the same document; -a receipt written before #101 is read with its lifecycle fields synthesized and -diagnosed (`AB7329`), never rejected. +only format 2 is read, and a copy carrying an older receipt is foreign. ```sh agent-bundle uninstall claude --from artifact --plan # exact paths and host verbs, no writer @@ -657,13 +656,15 @@ node artifact/install.mjs --uninstall [--plan] [--mode marketplace] Uninstall removes exactly what the receipt owns and reverses exactly the registrations it recorded; anything else stays and is listed as retained. -Legacy durable runtime state (`state/`) is kept unless `--purge-data --confirm-purge` -(current builds keep framework state outside the plugin root); +Durable runtime state (the framework state roots the receipt records with +ownership evidence) is kept unless `--purge-data --confirm-purge`; an unowned +`state/` directory beside the plugin is retained and listed, never purged; the typed `data.outcome` says what the host itself decided where Agent Bundle cannot (`retained-by-host` for Claude's ~14-day orphaned copy, -`removed-by-host` / `unavailable` for Codex, which has no keep-data option). A -missing receipt or an owned-content mismatch is refused (`AB7009`, `AB7007`) -unless `--force`; a receipt or manifest naming another plugin is refused +`removed-by-host` for Codex, which has no keep-data option). A missing store +receipt for a host-registered install or an owned-content mismatch is refused +(`AB7009`, `AB7007`) unless `--force`; a Cursor local directory without a +receipt, or a receipt or manifest naming another plugin, is foreign and refused regardless; `--purge-data` without `--confirm-purge` is `AB7008`; a second run is a `not-installed` no-op. `doctor --from` adds the lifecycle stage per host, placed → registered → enabled → active, each observed or typed `unavailable` diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index ae0373104..14cdf8788 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -136,8 +136,8 @@ manifests at files inside those payloads without compiling them. Payload files c | `agent-bundle build` | Build a validated artifact from source, plus the declared `dist/` package build. | | `agent-bundle prepack` | Run the release build, dry-run npm packing without scripts, and verify packaged outputs, artifact hashes, bins, and versions (`--output` and `--json` supported). | | `agent-bundle install ` | Install a built bundle into Claude, Codex, or Cursor (`--from`, `--scope`, `--replace`, `--mode local\|marketplace` for Cursor, and `--json` supported). Same-version content drift of an agent-bundle-managed install is replaced automatically; identical reruns are a no-op. | -| `agent-bundle uninstall ` | Remove a receipt-owned install and nothing else: the receipt's files and directories, the host registrations it recorded (`claude plugin uninstall --keep-data` + `marketplace remove`, `codex plugin remove` + `marketplace remove`, the Cursor local directory or staged marketplace). `--plan` prints the exact paths without changing anything; the effective framework state root, web-data, and legacy `state/` are kept unless `--purge-data --confirm-purge`; a missing receipt or content mismatch is refused unless `--force`; a rerun is `not-installed`. | -| `agent-bundle doctor` | Read-only host inspection: host probes, installed inventory, effective and legacy state roots with existence and writability, store receipts cross-checked against the host, and, with `--from`, the installed copy compared against the built artifact by version and content hash (`current`, `stale`, `version-mismatch`, `foreign`, `not-installed`) plus the lifecycle stage (placed → registered → enabled → active, unobservable stages typed `unavailable`). | +| `agent-bundle uninstall ` | Remove a receipt-owned install and nothing else: the receipt's files and directories, the host registrations it recorded (`claude plugin uninstall --keep-data` + `marketplace remove`, `codex plugin remove` + `marketplace remove`, the Cursor local directory or staged marketplace). `--plan` prints the exact paths without changing anything; the receipt-recorded framework state roots and web-data are kept unless `--purge-data --confirm-purge`; a missing store receipt or content mismatch is refused unless `--force`, and a Cursor directory without a receipt is foreign and refused regardless; a rerun is `not-installed`. | +| `agent-bundle doctor` | Read-only host inspection: host probes, installed inventory, effective state roots with existence and writability, store receipts cross-checked against the host, and, with `--from`, the installed copy compared against the built artifact by version and content hash (`current`, `stale`, `version-mismatch`, `foreign`, `not-installed`) plus the lifecycle stage (placed → registered → enabled → active, unobservable stages typed `unavailable`). | | `agent-bundle validate` | Validate project source, or an artifact with `--artifact`. | | `agent-bundle inspect` | Inspect the normalized model and each selected host projection's plan from source, with per-host component accounting: which skills, commands, rules, hooks, MCP surfaces, and scripts each host emits and, for every omission, whether the author excluded it or the host's pinned capability judgment (`degraded`/`unavailable`/`prohibited`, with reason) ruled it out. | | `agent-bundle inspect --bundler` | Dump the lowered Rspack config (post-`tools`-hatch merge, as Rslib/Rsbuild hand it to the compiler) for every generated output. | @@ -262,7 +262,7 @@ every host treats it differently. `agent-bundle install` and the emitted hash differs (a stale copy), install replaces it without a flag. Cursor replacement is in place and touches owned files only: stale owned files are removed, new files are renamed over their predecessors, and unowned entries - such as workspace-durable `state/` stores survive; if a rebuilt artifact + such as a `state/` directory beside the plugin survive; if a rebuilt artifact introduces a path an existing unowned file already occupies, replacement aborts before any change and names it. Claude replacement runs `claude plugin uninstall @ --scope --keep-data` @@ -271,14 +271,9 @@ every host treats it differently. `agent-bundle install` and the emitted cache stays stale. Codex replacement runs `codex plugin remove` before `marketplace add` + `add`, so files a rebuild removed do not linger. - **`--replace`.** Also replaces an agent-bundle install of - the same plugin at a *different* version, and adopts a Cursor copy that was - installed before receipts existed (recognised by its emitted `INSTALL.md` + - `install.mjs` and matching manifest name). A legacy copy has no owned-file - inventory, so adoption rewrites the files the new artifact ships and leaves - every other file in place (operator files, files an earlier rebuild dropped, - `state/`); those leftovers stay unowned under the new receipt, and later - same-version rebuilds replace automatically. A byte-identical legacy copy - under `--replace` reports `adopted` and changes no plugin file. + the same plugin at a *different* version. It does not apply to a directory + without a receipt naming this plugin: a Cursor copy placed before receipts + existed is foreign, byte-identical or not, and is removed by hand. - **Foreign installs are always refused.** A directory under the plugin name that is not an agent-bundle install of this plugin fails with `AB7005` and a content-hash comparison (`installed @ content vs @@ -324,8 +319,8 @@ receipt and remove exactly what it owns: install itself created. Unowned entries are retained and listed; when the root survives, a remnant receipt (owning no files) keeps the created host directories accountable for a later purge and lets Doctor explain the - directory. Reinstalling around preserved state is an `installed`, not a - foreign refusal. + directory. Reinstalling into a root that still carries that remnant receipt + is an `installed`, not a foreign refusal; without it the root is foreign. - Cursor marketplace: the staged repository after its `HEAD` matches the recorded commit, plus the receipt; a copy Cursor imported is Cursor-owned and reported `manual` with the Customize step. @@ -338,27 +333,29 @@ receipt and remove exactly what it owns: host no longer holds is `already-absent`, so an orphaned receipt is consumed without running a host verb. -Durable runtime state (`state/`: state kernel, notices journal; for a Cursor -copy of an Agent Plugins pack, also the `PLUGIN_DATA` directory the receipt -records), effective framework state, and web-data are kept by default; -`--purge-data --confirm-purge` removes them (`AB7008` without the confirmation). -The typed `data.outcome` is honest per host: Cursor `kept` / `purged` / `absent`; -Claude `retained-by-host` (Claude 2.1.257 orphans the cached copy for its ~14-day -grace period; a purge also removes external framework state, web-data, `state/`, -and `plugins/data//`); Codex reports external state as `kept` / `purged`, -while in-tree `state/` is removed by the host and cannot be kept (codex-cli -0.147.0 has no keep-data option). -An older receipt that records no state location never makes a root derived -from the current environment or home purgeable; it is reported unproven and -retained, including after a keep-data cycle. +Durable runtime state (the framework state roots the receipt's `state` block +records with ownership evidence, web-data, and for a Cursor copy of an Agent +Plugins pack the `PLUGIN_DATA` directory the receipt records) is kept by +default; `--purge-data --confirm-purge` removes the roots whose ownership is +provable (`AB7008` without the confirmation). A `state/` directory beside the +plugin is an unowned entry: retained and listed, never purged. The typed +`data.outcome` is honest per host: Cursor `kept` / `purged` / `absent`; Claude +`retained-by-host` (Claude 2.1.257 orphans the cached copy for its ~14-day +grace period; a purge also removes the owned framework state, web-data, and +`plugins/data//`); Codex reports external state as `kept` / `purged`, while +the cached tree is removed by the host (codex-cli 0.147.0 has no keep-data +option). A receipt with no `state` block never makes a root derived from the +current environment or home purgeable; it is reported unproven and retained. `--plan` reports the same exact paths and host verbs without opening a writer. -A missing receipt (`AB7009`) or an owned-content, version, or `HEAD` mismatch -(`AB7007`) is refused unless `--force`; a receipt or manifest naming another -plugin is refused regardless; a second run is a `not-installed` no-op. +A missing store receipt for a host-registered install (`AB7009`) or an +owned-content, version, or `HEAD` mismatch (`AB7007`) is refused unless +`--force`; a Cursor directory without a receipt, or a receipt or manifest +naming another plugin, is foreign and refused regardless; a second run is a +`not-installed` no-op. `agent-bundle doctor` inventories the receipt store per host and flags receipts -the host no longer honours (`AB7328`), reports receipts that predate format 2 as -migrated (`AB7329`; an identical `install` rerun rewrites them), and with +the host no longer honours (`AB7328`), treats a receipt that is not format 2 as +absent (the copy is then foreign, `AB7321`), and with `--from` reports the lifecycle stage per host (`AB7330`): placed → registered → enabled → active, each observed from `plugin list --json` (Claude/Codex `enabled` flags), the Cursor local directory, or the Cursor marketplace import diff --git a/packages/agent-bundle/src/contracts/discovery.ts b/packages/agent-bundle/src/contracts/discovery.ts index ce1766e8a..bbc984dad 100644 --- a/packages/agent-bundle/src/contracts/discovery.ts +++ b/packages/agent-bundle/src/contracts/discovery.ts @@ -64,7 +64,7 @@ export type DiscoveryRuntimeStatus = readonly startedAt?: string; readonly status: 'available'; }> - | Readonly<{ readonly status: 'failed' | 'unavailable' | 'unsupported' }>; + | Readonly<{ readonly status: 'failed' | 'unavailable' }>; export interface DiscoveryMcpServer { readonly name: string; diff --git a/packages/agent-bundle/src/events/ipc.ts b/packages/agent-bundle/src/events/ipc.ts index d83a70168..663545387 100644 --- a/packages/agent-bundle/src/events/ipc.ts +++ b/packages/agent-bundle/src/events/ipc.ts @@ -208,7 +208,7 @@ export type RequestEventRuntimeStatusOptions = Readonly<{ export type EventRuntimeStatusResult = | Readonly - | Readonly<{ readonly status: 'unavailable' | 'unsupported' }>; + | Readonly<{ readonly status: 'unavailable' }>; export const eventRuntimeEndpoint = (endpointId: string): string => { const hash = createHash('sha256').update(endpointId, 'utf8').digest('hex').slice(0, 32); @@ -1195,7 +1195,9 @@ const statusProgram = ( 'Event runtime status response does not match the wire schema.', )); } - if (response.data.status === 'error') return Object.freeze({ status: 'unsupported' as const }); + if (response.data.status === 'error') { + return yield* Effect.fail(transportError('runtime-failed', response.data.message)); + } return Object.freeze({ ...response.data.runtime, status: 'available' as const, diff --git a/packages/agent-bundle/src/install/commands.ts b/packages/agent-bundle/src/install/commands.ts index 58d423743..0a07b3a55 100644 --- a/packages/agent-bundle/src/install/commands.ts +++ b/packages/agent-bundle/src/install/commands.ts @@ -129,13 +129,13 @@ export const registerLifecycleCommands = (program: Command, options: LifecycleCo ) .option('--scope ', 'Host install scope', installScope, 'user') .option('--mode ', 'Cursor delivery mode to uninstall: local (default) or marketplace', installMode) - .option('--keep-data', 'Keep the plugin\'s durable runtime state (state/) in place; this is the default') - .option('--purge-data', 'Also remove the plugin\'s durable runtime state; requires --confirm-purge') + .option('--keep-data', 'Keep the plugin\'s durable runtime state in place; this is the default') + .option('--purge-data', 'Also remove the receipt-owned durable runtime state; requires --confirm-purge') .option('--confirm-purge', 'Confirm that --purge-data may delete durable state') .option( '--force', - 'Proceed without an install receipt (legacy or host-only install) or when owned content no longer matches the receipt; ' + - 'foreign directories are still refused', + 'Proceed when a host-only install has no store receipt or when owned content no longer matches the receipt; ' + + 'directories without a receipt naming this plugin are still refused', ) .option('--plan', 'Print the exact paths and host registrations that would be removed without changing anything') .option('--json', 'Write one machine-readable JSON document'); diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index 28f88ea47..c539195d5 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -13,7 +13,7 @@ import { import { mapConcurrent } from '../core/async.ts'; import { errorMessage, isErrno } from '../core/errors.ts'; import { readArtifactManifest } from '../build/manifest-file.ts'; -import { exists, isPreservedRuntimeRoot } from '../core/paths.ts'; +import { exists } from '../core/paths.ts'; import { isRecord } from '../core/strict-json.ts'; import { validateClaudePlugin, @@ -49,10 +49,8 @@ import { compareInstalledTree, describeContentComparison, installReceiptFile, - installReceiptFormat, installReceiptStoreDirectory, isRemnantReceipt, - isRuntimeStateRemnant, listStoredInstallReceipts, readInstallReceipt, readInstallReceiptFile, @@ -77,11 +75,7 @@ import { } from './cursor-hooks-registration.ts'; import { cursorMarketplacePluginPath, cursorMarketplaceRoot } from './cursor-marketplace.ts'; import { bundleInventory, installedBundleInventory, readBundleIdentity, type PluginIdentity } from './identity.ts'; -import { - inspectInstalledStateOwnership, - isRecordedDerivedStateRoot, - resolveInstalledStateRoots, -} from './state-root.ts'; +import { inspectInstalledStateOwnership, resolveInstalledStateRoots } from './state-root.ts'; export type DoctorHost = Exclude; export type DoctorHostProbeStatus = 'available' | 'failed' | 'unavailable'; @@ -126,12 +120,11 @@ export interface DoctorHostProbe { readonly version?: string; } -/** The install receipt an inventoried Cursor local copy carries, as read (a format/1 receipt is reported as migrated). */ +/** The install receipt an inventoried Cursor local copy carries, as read. */ export interface DoctorReceiptSummary { readonly contentHash: string; readonly format: string; readonly installedAt: string; - readonly migratedFrom?: string; readonly mode: InstallReceiptMode; readonly scope: InstallReceiptScope; readonly updatedAt: string; @@ -143,8 +136,6 @@ export interface DoctorFinding { 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. */ readonly operatorEnv?: DoctorOperatorEnvReport; /** @@ -211,7 +202,6 @@ export interface DoctorReceiptFinding { readonly contentHash: string; readonly format: string; readonly installedAt: string; - readonly migratedFrom?: string; readonly mode: InstallReceiptMode; readonly path: string; readonly plugin: string; @@ -231,7 +221,7 @@ export type DoctorRuntimeStatus = readonly startedAt?: string; readonly status: 'available'; }> - | Readonly<{ readonly status: 'failed' | 'unavailable' | 'unsupported' }>; + | Readonly<{ readonly status: 'failed' | 'unavailable' }>; export interface DoctorDurableStateStore { /** Main database plus any present `-wal` and `-shm` sidecars. */ @@ -260,11 +250,11 @@ export interface DoctorDurableStateReport { readonly directory: string; readonly exists: boolean; readonly findings: readonly DoctorDurableStateStore[]; - readonly ownership: 'derived' | 'legacy' | 'marker' | 'unowned' | 'unrecorded'; + readonly ownership: 'derived' | 'marker' | 'unowned' | 'unrecorded'; readonly ownershipReason?: string; readonly purgeable: boolean; readonly servers: readonly string[]; - readonly stateSource: 'derived' | 'legacy' | 'native'; + readonly stateSource: 'derived' | 'native'; readonly status: 'known' | 'warnings'; readonly summary: { readonly bytes: number; @@ -304,7 +294,7 @@ export interface DoctorInstallComparison { readonly installedContentHash?: string; readonly installedPath?: string; readonly installedVersion?: string; - /** Who owns the installed copy: an agent-bundle receipt, a legacy pre-receipt layout, a foreign directory, or the host's own cache. */ + /** Who owns the installed copy: an agent-bundle receipt, a foreign directory, or the host's own cache. */ readonly ownership?: InstalledTreeOwnership | 'host'; readonly status: DoctorInstallComparisonStatus; } @@ -477,7 +467,7 @@ const durableStateReport = ( stateSource: DoctorDurableStateReport['stateSource'], findings: readonly DoctorDurableStateStore[], diagnostics: readonly Diagnostic[], - ownership: DoctorDurableStateReport['ownership'] = stateSource === 'legacy' ? 'legacy' : 'unrecorded', + ownership: DoctorDurableStateReport['ownership'] = 'unrecorded', purgeable = false, servers: readonly string[] = [], ownershipReason?: string, @@ -509,9 +499,9 @@ const durableStateReport = ( * A recorded path elsewhere (a remnant moved between homes) or one since removed by hand is not preserved * state, and `uninstall` would not touch it either. */ -const preservedPluginData = async (pluginRoot: string, receipt: InstallReceipt | undefined): Promise => { - const recorded = receipt?.cursorExpansion?.pluginData; - if (receipt === undefined || recorded === undefined) return undefined; +const preservedPluginData = async (pluginRoot: string, receipt: InstallReceipt): Promise => { + const recorded = receipt.cursorExpansion?.pluginData; + if (recorded === undefined) return undefined; const cursorRoot = resolve(pluginRoot, '..', '..', '..'); if (recorded !== join(cursorRoot, 'agent-bundle', 'plugin-data', receipt.plugin)) return undefined; for (const directory of [join(cursorRoot, 'agent-bundle'), join(cursorRoot, 'agent-bundle', 'plugin-data'), recorded]) { @@ -535,40 +525,24 @@ const preservedPluginData = async (pluginRoot: string, receipt: InstallReceipt | * A remnant receipt (owning no files) may also guard unowned entries the uninstall retained, so * the message reports those extras instead of calling the directory state-only. */ -const remnantDiagnostic = async (subject: string, path: string, receipt: InstallReceipt | undefined): Promise => { - const allEntries = (await readdir(path)).filter((name) => name !== installReceiptFile); - const extras = allEntries.filter((name) => !isPreservedRuntimeRoot(name)).sort((left, right) => left.localeCompare(right)); - // Preserved state is what `uninstall` would still keep: a state/ that holds something (an emptied one is pruned on - // the next run, like the remnant itself) and this home's real, non-empty PLUGIN_DATA directory. Nothing is - // assumed: a remnant whose preserved data has since gone is reported as exactly that. - const stateRoots = allEntries.filter(isPreservedRuntimeRoot); - let stateHeld = false; - for (const name of stateRoots) { - try { - if ((await readdir(join(path, name))).length > 0) stateHeld = true; - } catch { - stateHeld = true; - } - } +const remnantDiagnostic = async (subject: string, path: string, receipt: InstallReceipt): Promise => { + const extras = (await readdir(path)).filter((name) => name !== installReceiptFile).sort((left, right) => left.localeCompare(right)); + // Preserved state is what `uninstall` would still keep: this home's real, non-empty PLUGIN_DATA directory. Nothing + // is assumed: a remnant whose preserved data has since gone is reported as exactly that. const pluginData = await preservedPluginData(path, receipt); - const preserved = [ - ...(stateHeld ? ['state/'] : []), - ...(pluginData === undefined ? [] : [`the PLUGIN_DATA directory ${pluginData}`]), - ]; - const preservedText = preserved.join(' and '); return diagnostic( 'AB7307', extras.length === 0 - ? preserved.length === 0 + ? pluginData === undefined ? `${subject} holds only the remnant receipt of an earlier \`uninstall --keep-data\` whose preserved runtime state has since ` + 'been removed; no plugin is installed there.' - : `${subject} holds only preserved runtime state (${preservedText}) from an earlier \`uninstall --keep-data\`; ` + + : `${subject} holds only preserved runtime state (the PLUGIN_DATA directory ${pluginData}) from an earlier \`uninstall --keep-data\`; ` + 'no plugin is installed there.' : `${subject} holds no plugin: an earlier \`uninstall\` retained the unowned ` + `${extras.length === 1 ? 'entry' : 'entries'} ${extras.map((name) => JSON.stringify(name)).join(', ')}` + - `${preserved.length === 0 ? '' : ` beside preserved runtime state (${preservedText})`}.`, + `${pluginData === undefined ? '' : ` beside preserved runtime state (the PLUGIN_DATA directory ${pluginData})`}.`, extras.length === 0 - ? preserved.length === 0 + ? pluginData === undefined ? 'Run `agent-bundle uninstall cursor` (or the bundle\'s `install.mjs --uninstall`) to consume the remnant, or reinstall the plugin.' : 'Reinstall the plugin to use the preserved state, or run `agent-bundle uninstall cursor --purge-data --confirm-purge` to remove it.' : 'Reinstall the plugin, or move the retained entries out and remove the directory by hand; `uninstall` never removes unowned entries.', @@ -657,7 +631,6 @@ const inspectInstalledDurableState = async ( readonly diagnostics: readonly Diagnostic[]; readonly effective: DoctorDurableStateReport; readonly effectiveAll: readonly DoctorDurableStateReport[]; - readonly legacy?: DoctorDurableStateReport; }> => { const locations = await resolveInstalledStateRoots(pluginRoot, host, environment, home); const grouped = new Map(); @@ -667,22 +640,12 @@ const inspectInstalledDurableState = async ( if (current === undefined) grouped.set(location.root, { servers: [location.server], source: location.source }); else current.servers.push(location.server); } - const recordedLegacyRoot = receipt?.state === undefined ? receipt?.stateRoot : undefined; - if (recordedLegacyRoot !== undefined && !grouped.has(recordedLegacyRoot.root)) { - grouped.set(recordedLegacyRoot.root, { - servers: [], - source: recordedLegacyRoot.source === 'derived' ? 'derived' : 'declared', - }); - } const effectiveAll: DoctorDurableStateReport[] = []; for (const [root, current] of grouped) { const recorded = receipt?.state?.roots.find((candidate) => candidate.root === root); - const legacyPurgeable = receipt?.state === undefined && - isRecordedDerivedStateRoot(receipt?.stateRoot, root); const decision = recorded === undefined || receipt?.state === undefined ? undefined : await inspectInstalledStateOwnership(receipt.state, recorded); - const ownership = recorded?.ownership.kind ?? (legacyPurgeable ? 'derived' : 'unrecorded'); const inspected = await inspectDurableState( root, current.source === 'derived' ? 'derived' : 'native', @@ -690,11 +653,11 @@ const inspectInstalledDurableState = async ( ); effectiveAll.push(Object.freeze({ ...inspected, - ownership, + ownership: recorded?.ownership.kind ?? 'unrecorded', ...(recorded?.ownership.kind === 'unowned' ? { ownershipReason: recorded.ownership.reason } : decision?.reason === undefined ? {} : { ownershipReason: decision.reason }), - purgeable: decision?.action === 'purge' || legacyPurgeable, + purgeable: decision?.action === 'purge', servers: Object.freeze(current.servers), })); } @@ -714,29 +677,10 @@ const inspectInstalledDurableState = async ( '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 (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: 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(effective.directory)}.`, - reportedAll.some((entry) => entry.purgeable) - ? 'Run `agent-bundle uninstall --purge-data --confirm-purge` to remove the legacy in-tree root and all receipt-owned effective roots; unrecorded effective roots remain retained. Move required data before deleting any other directory by hand.' - : `Run \`agent-bundle uninstall --purge-data --confirm-purge\` to remove the legacy in-tree root; it retains the ${effective.ownership} effective root because the receipt does not prove exclusive ownership. Move required data before deleting either directory by hand.`, - 'info', - host, - ); return { - diagnostics: freezeDiagnostics([...effectiveDiagnostics, ...legacy.diagnostics, legacyDiagnostic]), + diagnostics: freezeDiagnostics(reportedAll.flatMap((entry) => entry.diagnostics)), effective, effectiveAll: reportedAll, - legacy, }; }; @@ -1093,20 +1037,19 @@ const cursorInventory = async ( } catch { remnantReceipt = undefined; } - const stateOnly = await isRuntimeStateRemnant(path); - const remnant = stateOnly || (remnantReceipt !== undefined && isRemnantReceipt(remnantReceipt)); - if (remnant) { + if (remnantReceipt !== undefined && isRemnantReceipt(remnantReceipt)) { const durableState = await inspectInstalledDurableState(path, 'cursor', environment, home, remnantReceipt); diagnostics.push(...durableState.diagnostics); 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 }), + name: remnantReceipt.plugin, path, + receipt: receiptSummary(remnantReceipt), state: 'missing', + version: remnantReceipt.version, }); continue; } @@ -1168,13 +1111,9 @@ const cursorInventory = async ( ? 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 }), operatorEnv, @@ -1369,7 +1308,6 @@ const publicHostInventory = async ( ...(errors.length === 0 ? {} : { errors }), name, path: row['installPath'], - ...(durableState.legacy === undefined ? {} : { legacyDurableState: durableState.legacy }), state: errors.length > 0 ? 'failed' : enabled === false ? 'disabled' : 'installed', version: row['version'], }); @@ -1397,7 +1335,6 @@ const publicHostInventory = async ( entry: row['pluginId'], name, path, - ...(durableState.legacy === undefined ? {} : { legacyDurableState: durableState.legacy }), state: 'installed', version: row['version'], }); @@ -1593,25 +1530,13 @@ const lifecycleDiagnostic = (host: DoctorHost, name: string, version: string, li const receiptSummary = (receipt: InstallReceipt): DoctorReceiptSummary => Object.freeze({ contentHash: receipt.contentHash, - format: receipt.migratedFrom ?? installReceiptFormat, + format: receipt.format, installedAt: receipt.installedAt, - ...(receipt.migratedFrom === undefined ? {} : { migratedFrom: receipt.migratedFrom }), mode: receipt.mode, scope: receipt.scope, updatedAt: receipt.updatedAt, }); -const migratedReceiptDiagnostic = (host: DoctorHost, path: string, receipt: InstallReceipt): Diagnostic => diagnostic( - 'AB7329', - `Install receipt ${JSON.stringify(path)} predates lifecycle receipts (read as ${receipt.migratedFrom ?? 'an older format'}): ` + - `mode, scope, registrations, and host directories were synthesized (${receipt.mode}, ${receipt.scope}, ` + - `${receipt.registrations.map((registration) => registration.kind).join(', ')}, none).`, - 'Rerun `agent-bundle install` (or the bundle\'s `install.mjs`) once; an identical copy rewrites the receipt as ' + - `${installReceiptFormat} without changing plugin files. \`uninstall\` accepts the migrated receipt as is.`, - 'info', - host, -); - /** Whether the host's listing still names the plugin registration a store receipt records. */ const receiptRegistrationState = ( host: Exclude, @@ -1640,9 +1565,8 @@ const receiptRegistrationState = ( const receiptFinding = (path: string, receipt: InstallReceipt, state: DoctorReceiptFinding['state']): DoctorReceiptFinding => Object.freeze({ contentHash: receipt.contentHash, - format: receipt.migratedFrom ?? installReceiptFormat, + format: receipt.format, installedAt: receipt.installedAt, - ...(receipt.migratedFrom === undefined ? {} : { migratedFrom: receipt.migratedFrom }), mode: receipt.mode, path, plugin: receipt.plugin, @@ -1656,8 +1580,8 @@ const receiptFinding = (path: string, receipt: InstallReceipt, state: DoctorRece /** * Inventories the Agent Bundle store receipts under a host root and * cross-checks each against the host: an orphaned receipt (the registration it - * records is gone) is `AB7328`, a pre-lifecycle receipt is `AB7329`. Unreadable - * receipt files are reported, never thrown. + * records is gone) is `AB7328`. Unreadable receipt files are reported, never + * thrown. */ const inspectStoreReceipts = async ( host: DoctorHost, @@ -1710,7 +1634,6 @@ const inspectStoreReceipts = async ( } const state = await stateOf(receipt); receipts.push(receiptFinding(path, receipt, state)); - if (receipt.migratedFrom !== undefined) diagnostics.push(migratedReceiptDiagnostic(host, path, receipt)); if (state === 'orphaned') { diagnostics.push(diagnostic( 'AB7328', @@ -2070,13 +1993,9 @@ const cursorBundle = async ( ...(comparison.receipt === undefined ? {} : { receipt: receiptSummary(comparison.receipt) }), state, }); - // `uninstall --keep-data` left state/ (with a remnant receipt owning no files) and possibly unowned entries it - // retained: not installed, state kept. The remnant receipt alone does not prove the directory is state-only. - const stateOnly = (comparison.ownership === 'receipt' || comparison.ownership === 'foreign') && - await isRuntimeStateRemnant(destination); - const remnant = stateOnly || - (comparison.ownership === 'receipt' && comparison.receipt !== undefined && isRemnantReceipt(comparison.receipt)); - if (remnant) { + // `uninstall --keep-data` left a remnant receipt owning no files beside the entries it retained: not + // installed, data kept. + if (comparison.receipt !== undefined && isRemnantReceipt(comparison.receipt)) { return { diagnostics: freezeDiagnostics([await remnantDiagnostic( `Cursor destination ${destination} (${identity.plugin}@${identity.version})`, @@ -2087,7 +2006,7 @@ const cursorBundle = async ( ...base, comparison: Object.freeze({ artifactContentHash: artifact.hash, status: 'not-installed' as const }), lifecycle: cursorLocalLifecycle(destination, false), - ...(comparison.receipt === undefined ? {} : { receipt: receiptSummary(comparison.receipt) }), + receipt: receiptSummary(comparison.receipt), state: 'missing', }), }; @@ -2125,11 +2044,8 @@ const cursorBundle = async ( 'AB7308', `Cursor plugin ${identity.plugin}@${identity.version} at ${destination} is stale ` + `(same version, different content): ${detail}.`, - comparison.ownership === 'receipt' - ? 'Rerun `agent-bundle install cursor --from ` or `install.mjs`; ' + - 'same-version content drift of a receipt-managed install is replaced automatically.' - : 'This copy predates install receipts; rerun `agent-bundle install cursor --from --replace` ' + - '(or `install.mjs --replace`) once to adopt it.', + 'Rerun `agent-bundle install cursor --from ` or `install.mjs`; ' + + 'same-version content drift of a receipt-managed install is replaced automatically.', 'warning', 'cursor', )]), @@ -2513,14 +2429,7 @@ const inspectSocketEndpoint = async (path: string): Promise try { const probed = await requestEventRuntimeStatus({ endpoint: path, timeoutMs: doctorRuntimeStatusTimeoutMs }); runtime = probed; - if (probed.status === 'unsupported') { - diagnostics.push(diagnostic( - 'AB7317', - `Runtime socket ${JSON.stringify(path)} predates read-only runtime identity introspection.`, - 'Restart the runtime after upgrading Agent Bundle to expose its process-lifetime identity.', - 'info', - )); - } else if (probed.status === 'unavailable') { + if (probed.status === 'unavailable') { diagnostics.push(diagnostic( 'AB7318', `Runtime socket ${JSON.stringify(path)} became unavailable during its status probe.`, @@ -2776,9 +2685,9 @@ const doctorHost = async ( : await publicHostInventory(host, listing, environment, home); const diagnostics = [...probed.diagnostics, ...inventoried.diagnostics]; // Store receipts are lifecycle evidence Agent Bundle itself wrote, so the store is inventoried from - // the filesystem whether or not the host can be probed: malformed and migrated receipts are always - // reported. The host cross-check that separates `consistent` from `orphaned` needs the host's - // inventory; without it the registration state is `unknown`, never guessed. + // the filesystem whether or not the host can be probed: malformed receipts are always reported. The host + // cross-check that separates `consistent` from `orphaned` needs the host's inventory; without it the + // registration state is `unknown`, never guessed. const receipts = host === 'cursor' ? await inspectStoreReceipts(host, join(home, '.cursor'), async (receipt) => receipt.mode === 'marketplace' ? (await exists(join(cursorMarketplaceRoot(join(home, '.cursor')), receipt.plugin)) ? 'consistent' : 'orphaned') @@ -2831,8 +2740,6 @@ const doctorHost = async ( if (checked.finding.lifecycle !== undefined) { diagnostics.push(lifecycleDiagnostic(host, identity.plugin, identity.version, checked.finding.lifecycle)); } - const durableState = await inspectDurableState(join(identity.bundleRoot, 'state'), 'legacy', host); - diagnostics.push(...durableState.diagnostics); const operatorEnv = await inspectOperatorEnv(identity.bundleRoot, host); diagnostics.push(...operatorEnv.diagnostics); bundle = Object.freeze({ @@ -2840,7 +2747,6 @@ const doctorHost = async ( ...(staticDiagnostics.some((entry) => entry.severity === 'error') ? { state: 'corrupt' as const } : {}), - ...(durableState.exists ? { durableState } : {}), operatorEnv, }); } catch (error) { diff --git a/packages/agent-bundle/src/install/format.ts b/packages/agent-bundle/src/install/format.ts index 2296d82fd..80bab8062 100644 --- a/packages/agent-bundle/src/install/format.ts +++ b/packages/agent-bundle/src/install/format.ts @@ -1,6 +1,5 @@ import { formatByteSize } from '../core/strings.ts'; import type { - DoctorDurableStateReport, DoctorInstallComparison, DoctorLifecycle, DoctorReport, @@ -12,8 +11,6 @@ const shortContentHash = (hash: string): string => hash.slice(0, 12); const installVerb = (state: InstallResult['state'], mode: InstallResult['mode']): string => { switch (state) { - case 'adopted': - return 'Adopted'; case 'replaced': return 'Replaced'; case 'already-installed': @@ -185,12 +182,9 @@ export const formatDoctorReport = (result: DoctorReport): string => { out.push(` ${receipt.plugin}@${receipt.version} (${receipt.mode}, ${receipt.scope}): ${receipt.state}\n`); } } - const reports = [ - ...host.inventory.findings.flatMap((finding) => finding.durableStates ?? ( - finding.durableState === undefined ? [] : [finding.durableState] - )), - host.bundle?.durableState, - ].filter((report): report is DoctorDurableStateReport => report !== undefined); + const reports = host.inventory.findings.flatMap((finding) => finding.durableStates ?? ( + finding.durableState === undefined ? [] : [finding.durableState] + )); const uniqueReports = [...new Map(reports.map((report) => [report.directory, report])).values()]; for (const report of uniqueReports) { out.push( @@ -202,12 +196,6 @@ export const formatDoctorReport = (result: DoctorReport): string => { }\n`, ); } - const legacyReports = host.inventory.findings - .map((finding) => finding.legacyDurableState) - .filter((report): report is DoctorDurableStateReport => report !== undefined); - for (const report of [...new Map(legacyReports.map((entry) => [entry.directory, entry])).values()]) { - out.push(` legacy state: ${report.directory} (exists, ${report.writable ? 'writable' : 'not writable'})\n`); - } if (uniqueReports.length > 0) { const stores = uniqueReports.reduce((total, report) => total + report.summary.stores, 0); const bytes = uniqueReports.reduce((total, report) => total + report.summary.bytes, 0); diff --git a/packages/agent-bundle/src/install/install.ts b/packages/agent-bundle/src/install/install.ts index 5e0025d4f..7dd4056e5 100644 --- a/packages/agent-bundle/src/install/install.ts +++ b/packages/agent-bundle/src/install/install.ts @@ -29,7 +29,6 @@ import { installReceiptScopeKey, installReceiptStorePath, isRemnantReceipt, - isRuntimeStateRemnant, readInstallReceipt, readInstallReceiptFile, replaceInstalledTree, @@ -50,11 +49,7 @@ export type InstallHost = BundleIdentityHost; export type DevInstallHost = Exclude; export type PublicInstallHost = Exclude; export type InstallScope = 'local' | 'project' | 'user'; -/** - * `adopted`: a byte-identical pre-receipt Cursor copy gained its receipt under - * `--replace`; no plugin file changed. - */ -export type InstallResultState = 'adopted' | 'already-installed' | 'installed' | 'replaced' | 'staged'; +export type InstallResultState = 'already-installed' | 'installed' | 'replaced' | 'staged'; /** * Cursor delivery mode: `local` copies into `~/.cursor/plugins/local/` @@ -811,10 +806,8 @@ const collisionMessage = ( return `Refusing foreign install at ${destination}: ${detail}; the directory is not an agent-bundle install of ` + `${identity.plugin}, so --replace does not apply. Remove it manually if it is stale.`; case 'version-mismatch': - return `Refusing version collision at ${destination}: ${detail}. Re-run with --replace to replace this agent-bundle install.`; case 'stale': - return `Refusing content collision at ${destination}: ${detail}; this copy predates install receipts. ` + - 'Re-run with --replace once to adopt it; later same-version rebuilds replace automatically.'; + return `Refusing version collision at ${destination}: ${detail}. Re-run with --replace to replace this agent-bundle install.`; case 'current': return `Install at ${destination} is current.`; default: { @@ -1045,56 +1038,24 @@ const installCursor = Effect.fnUntraced(function*( return { ...base, contentHash: artifact.hash, state: 'already-installed' } as const; } const installedManifest = yield* liftPromise(() => readInstalledManifest(destination)); - const compared = yield* liftPromise(() => compareInstalledTree({ + const comparison = yield* liftPromise(() => compareInstalledTree({ artifact, destination, installedManifest, plugin: identity.plugin, version: identity.version, })); - // `uninstall --keep-data` leaves a shell holding only state/ (plus, normally, a remnant receipt that owns no - // files): a reinstall fills it back in around the preserved durable state instead of refusing it as foreign - // (nothing in it is anyone's plugin content) and reports an install, not a replacement. - const remnant = compared.ownership === 'receipt' && compared.receipt !== undefined - ? isRemnantReceipt(compared.receipt) - : compared.ownership === 'foreign' && (yield* liftPromise(() => isRuntimeStateRemnant(destination))); - const comparison: InstalledTreeComparison = remnant && compared.ownership === 'foreign' - ? { ...compared, ownership: 'legacy', status: 'stale' } - : compared; + // `uninstall --keep-data` leaves a remnant receipt that owns no files: a reinstall fills the shell back in + // around the retained entries and reports an install, not a replacement. + const remnant = comparison.receipt !== undefined && isRemnantReceipt(comparison.receipt); 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. - 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 - // lifecycle fields are synthesized exactly as the reader migrates them, and nothing else changes. - if (comparison.ownership === 'receipt' && comparison.receipt?.migratedFrom !== undefined) { - const previous = comparison.receipt; - yield* liftPromise(() => writeInstallReceipt(destination, createInstallReceipt({ - ...receipt, - directories: previous.directories, - hostDirectories: previous.hostDirectories, - installedAt: previous.installedAt, - inventory: artifact, - 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 - ? true - : comparison.status !== 'foreign' && options.replace === true; + const replaceable = comparison.ownership === 'receipt' && + (comparison.status === 'stale' || remnant || options.replace === true); if (!replaceable) { return yield* Effect.fail(failure('AB7005', collisionMessage(destination, identity, comparison), 'cursor')); } @@ -1116,7 +1077,7 @@ const installCursor = Effect.fnUntraced(function*( home, comparison.receipt?.state, )); - // Filling a state-only shell is a fresh install of plugin content, not a replacement of any. + // Filling a remnant 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 { ...base, diff --git a/packages/agent-bundle/src/install/receipt.ts b/packages/agent-bundle/src/install/receipt.ts index e33dba0c0..0e7c6435c 100644 --- a/packages/agent-bundle/src/install/receipt.ts +++ b/packages/agent-bundle/src/install/receipt.ts @@ -22,7 +22,7 @@ import { isErrno } from '../core/errors.ts'; import { packageBinEntries } from '../core/package-dependencies.ts'; import { isRecord } from '../core/strict-json.ts'; import { matchesManifestFile } from '../build/artifact-layout.ts'; -import { exists, installReceiptFile, isInstallReceiptEntry, isPortablePathSegment, isPreservedRuntimeRoot } from '../core/paths.ts'; +import { installReceiptFile, isInstallReceiptEntry, isPortablePathSegment, 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'; @@ -32,24 +32,21 @@ import { OPERATOR_ENV_FILE_NAMES } from '../launch-env.ts'; * `agent-bundle doctor`, and the emitted standalone `install.mjs`: every copy * an agent-bundle installer places at a plugin root carries a receipt naming * the plugin, its version, the artifact content hash, and the exact files the - * installer owns. Replacement removes or rewrites owned files only, so runtime - * state that lands beside the plugin (`state/`) survives a same-version rebuild. + * installer owns. Replacement removes or rewrites owned files only, so + * unowned entries beside the plugin survive a same-version rebuild. */ /** Sidecar written at an installed plugin root by every agent-bundle installer. */ export { installReceiptFile }; /** - * Current receipt format. Format 2 (#101) adds the lifecycle fields — - * `mode`, `scope`, `registrations`, `hostDirectories`, `updatedAt` — that - * `agent-bundle uninstall` consumes; format 1 receipts (#420) are read with - * those fields synthesized (`migratedFrom` names the downgrade) and are - * rewritten as format 2 by the next replacement. + * The only receipt format read or written. Format 2 (#101) carries the + * lifecycle fields — `mode`, `scope`, `registrations`, `hostDirectories`, + * `updatedAt` — that `agent-bundle uninstall` consumes; any other format + * reads as absent, so the tree it sits in is foreign. */ export const installReceiptFormat = 'agent-bundle-install-receipt/2'; -export const legacyInstallReceiptFormat = 'agent-bundle-install-receipt/1'; - /** * How the install was delivered: `local` copies into a host-loaded directory * (Cursor `plugins/local`, Amp project/system plugins), `marketplace` stages a local marketplace repository @@ -98,13 +95,6 @@ export interface InstallRegistration { } -/** - * Root files every emitted Cursor-compatible bundle carries. A receipt-less - * copy with these files and a matching manifest name is a legacy agent-bundle - * install (placed before receipts existed); anything else is foreign. - */ -export const installSurfaceMarkerFiles: readonly string[] = Object.freeze(['INSTALL.md', 'install.mjs']); - /** * Recorded by the emitted `install.mjs` when it installs an Agent Plugins * pack into `~/.cursor/plugins/local`: Cursor 3.18.25 expands none of the @@ -167,12 +157,6 @@ export interface InstallReceipt { */ readonly hostDirectories: readonly string[]; readonly installedAt: string; - /** - * Present only on a receipt read from disk that predates the current - * format: names the format it was read as, and every lifecycle field was - * synthesized (best-effort defaults). Never written. - */ - readonly migratedFrom?: string; readonly mode: InstallReceiptMode; readonly plugin: string; /** @@ -187,11 +171,6 @@ export interface InstallReceipt { 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; - readonly source: 'derived' | 'native'; - }; /** When this receipt was last written (install or replacement); `installedAt` is the first install. */ readonly updatedAt: string; readonly version: string; @@ -220,7 +199,7 @@ export interface TreeInventory { readonly hash: string; } -export type InstalledTreeOwnership = 'foreign' | 'legacy' | 'receipt'; +export type InstalledTreeOwnership = 'foreign' | 'receipt'; export type InstalledTreeStatus = 'current' | 'foreign' | 'stale' | 'version-mismatch'; @@ -680,18 +659,13 @@ const readRegistration = (value: unknown): InstallRegistration | undefined => { }; /** - * Validates a parsed receipt document. Format 1 receipts (written by #420 - * for Cursor local copies) are upgraded in memory: `mode: 'local'`, - * `scope: 'user'`, a single `cursor-local-plugin` registration, no host - * directories, and `updatedAt = installedAt`; `migratedFrom` records the - * downgrade so Doctor can diagnose it. Any other format, or a current-format - * receipt missing a field, reads as absent. + * Validates a parsed receipt document. Any format other than the current + * one, or a receipt missing a field, reads as absent. */ const receiptFromDocument = (value: unknown): InstallReceipt | undefined => { if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined; const record = value as Record; - const format = record['format']; - if (format !== installReceiptFormat && format !== legacyInstallReceiptFormat) return undefined; + if (record['format'] !== installReceiptFormat) return undefined; if ( typeof record['plugin'] !== 'string' || typeof record['version'] !== 'string' || @@ -706,43 +680,6 @@ const receiptFromDocument = (value: unknown): InstallReceipt | 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 && - typeof stateRootRecord === 'object' && - !Array.isArray(stateRootRecord) && - typeof (stateRootRecord as Record)['root'] === 'string' && - ((stateRootRecord as Record)['source'] === 'derived' || - (stateRootRecord as Record)['source'] === 'native') - ? Object.freeze({ - root: (stateRootRecord as Record)['root'] as string, - source: (stateRootRecord as Record)['source'] as 'derived' | 'native', - }) - : undefined; - const base = { - contentHash: record['contentHash'], - ...(cursorExpansion === undefined ? {} : { cursorExpansion }), - directories: Object.freeze([...record['directories']]), - files: Object.freeze([...record['files']]), - format: installReceiptFormat, - host: record['host'], - installedAt: record['installedAt'], - plugin: record['plugin'], - ...(stateRoot === undefined ? {} : { stateRoot }), - version: record['version'], - ...(typeof record['webDataRoot'] === 'string' ? { webDataRoot: record['webDataRoot'] } : {}), - } as const; - if (format === legacyInstallReceiptFormat) { - return Object.freeze({ - ...base, - hostDirectories: Object.freeze([]), - migratedFrom: legacyInstallReceiptFormat, - mode: 'local', - registrations: Object.freeze([Object.freeze({ kind: 'cursor-local-plugin' as const })]), - scope: 'user', - updatedAt: record['installedAt'], - }); - } if ( !isReceiptMode(record['mode']) || !isReceiptScope(record['scope']) || @@ -767,14 +704,23 @@ const receiptFromDocument = (value: unknown): InstallReceipt | undefined => { ? state : undefined; return Object.freeze({ - ...base, + contentHash: record['contentHash'], + ...(cursorExpansion === undefined ? {} : { cursorExpansion }), + directories: Object.freeze([...record['directories']]), + files: Object.freeze([...record['files']]), + format: installReceiptFormat, + host: record['host'], hostDirectories: Object.freeze([...record['hostDirectories']]), + installedAt: record['installedAt'], mode: record['mode'], + plugin: record['plugin'], ...(typeof record['projectRoot'] === 'string' ? { projectRoot: record['projectRoot'] } : {}), registrations: Object.freeze(registrations), scope: record['scope'], ...(ownedState === undefined ? {} : { state: ownedState }), updatedAt: record['updatedAt'], + version: record['version'], + ...(typeof record['webDataRoot'] === 'string' ? { webDataRoot: record['webDataRoot'] } : {}), }); }; @@ -803,15 +749,14 @@ export const readInstallReceipt = (destination: string): Promise { const installedAt = options.installedAt ?? new Date().toISOString(); @@ -841,18 +786,13 @@ export const createInstallReceipt = (options: InstallReceiptIdentity & { }))), }), }), - ...(options.stateRoot === undefined ? {} : { stateRoot: Object.freeze({ ...options.stateRoot }) }), updatedAt: options.updatedAt ?? installedAt, version: options.version, ...(options.webDataRoot === undefined ? {} : { webDataRoot: options.webDataRoot }), }); }; -/** The on-disk document: `migratedFrom` is a read-time annotation and is never persisted. */ -const receiptDocument = (receipt: InstallReceipt): string => { - const { migratedFrom: _migratedFrom, ...persisted } = receipt; - return `${stableJson({ ...persisted, format: installReceiptFormat })}\n`; -}; +const receiptDocument = (receipt: InstallReceipt): string => `${stableJson(receipt)}\n`; /** * Writes a receipt document atomically at `path`: an exclusively created, @@ -1020,17 +960,6 @@ export const simulateRemoveStoredInstallReceipt = async (path: string, hostRoot: /** Removes an empty directory; reports whether it was removed (non-empty or absent directories are left alone). */ export const pruneEmptyDirectory = rmdirIfEmpty; -/** - * A destination that holds nothing but runtime roots (`state/`) — and at most - * a remnant receipt — is what `uninstall --keep-data` leaves behind: not a - * foreign directory, but an empty shell around preserved durable state that a - * reinstall fills back in. - */ -export const isRuntimeStateRemnant = async (destination: string): Promise => { - const entries = (await readdir(destination)).filter((name) => name !== installReceiptFile); - return entries.length > 0 && entries.every(isPreservedRuntimeRoot); -}; - /** * A remnant receipt owns no files and records no registrations: `uninstall` * writes it when the plugin root survives (retained runtime state or unowned @@ -1044,18 +973,11 @@ export const isRemnantReceipt = (receipt: InstallReceipt): boolean => /** `sha256` of an empty owned set: what `hashOwnedFiles(root, [])` yields, and what a remnant receipt records. */ export const emptyContentHash = createHash('sha256').digest('hex'); -export const hasInstallSurfaceMarkers = async (destination: string): Promise => { - for (const marker of installSurfaceMarkerFiles) { - if (!await exists(join(destination, marker))) return false; - } - return true; -}; - /** * Decides whether an existing plugin root is this plugin's agent-bundle - * install (receipt or legacy layout) or a foreign directory, and whether its - * content matches the artifact. Symlinks anywhere in the installed tree are - * refused, exactly like the copy paths. + * install (a receipt naming this plugin) or a foreign directory, and whether + * its content matches the artifact. Symlinks anywhere in the installed tree + * are refused, exactly like the copy paths. */ export const compareInstalledTree = async (options: { readonly artifact: TreeInventory; @@ -1075,9 +997,7 @@ export const compareInstalledTree = async (options: { installedContentHash = await hashOwnedFiles(options.destination, receipt.files); } else { installedContentHash = (await treeInventory(options.destination)).hash; - ownership = receipt === undefined && manifest?.name === options.plugin && await hasInstallSurfaceMarkers(options.destination) - ? 'legacy' - : 'foreign'; + ownership = 'foreign'; } const installedVersion = manifest?.version ?? (ownership === 'receipt' ? receipt?.version : undefined); const installedName = manifest?.name ?? (ownership === 'receipt' ? receipt?.plugin : undefined); @@ -1247,27 +1167,6 @@ const ensureAncestors = async ( } }; -/** - * What the previous installer owned. A receipt says so exactly. A legacy copy - * has no inventory, so only the files the new artifact also ships count as - * owned: they are rewritten, everything else (operator files, stale artifact - * files, `state/`) is left in place and stays unowned under the new receipt. - */ -const previouslyOwnedFiles = async ( - destination: string, - comparison: InstalledTreeComparison, - incoming: ReadonlySet, -): Promise => { - if (comparison.ownership === 'receipt' && comparison.receipt !== undefined) return comparison.receipt.files; - const inventory = await treeInventory(destination); - const owned: string[] = []; - for (const file of inventory.files) { - // Exact match, or a case alias of an incoming path on case-insensitive filesystems. - if (await isOwnedEntry(destination, incoming, file)) owned.push(file); - } - return owned; -}; - /** * True when a directory is entirely previous-installer territory: the * directory and every directory beneath it were created by the installer, it @@ -1302,13 +1201,6 @@ const isWhollyOwnedDirectory = async ( return await visit(relativePath) && files > 0; }; -/** - * Directories the previous installer created. A receipt says so exactly; a - * legacy copy has no inventory, so none of its directories are ours. - */ -const previouslyOwnedDirectories = (comparison: InstalledTreeComparison): readonly string[] => - comparison.ownership === 'receipt' && comparison.receipt !== undefined ? comparison.receipt.directories : []; - /** * Replaces an agent-bundle-owned install in place: stale owned files leave * first (their now-empty installer-created directories are pruned), every @@ -1325,13 +1217,14 @@ export const replaceInstalledTree = async (options: { readonly receipt: InstallReceiptIdentity; readonly staged: StagedArtifact; }): Promise => { - if (options.comparison.ownership === 'foreign') { + const previous = options.comparison.receipt; + if (options.comparison.ownership === 'foreign' || previous === undefined) { throw new Error(`Refusing to replace foreign install at ${options.destination}.`); } const incoming = new Set(options.staged.inventory.files); - const previouslyOwned = await previouslyOwnedFiles(options.destination, options.comparison, incoming); + const previouslyOwned = previous.files; const owned = new Set(previouslyOwned); - const ownedDirectories = new Set(previouslyOwnedDirectories(options.comparison)); + const ownedDirectories = new Set(previous.directories); await assertRealAncestors(options.destination, previouslyOwned); await assertRealAncestors(options.destination, options.staged.inventory.files, owned); // An existing entry at an incoming path is fine when it is the owned file itself (exact name, or @@ -1378,15 +1271,14 @@ export const replaceInstalledTree = async (options: { ...[...ownedDirectories].filter((directory) => !pruned.has(directory)), ...created, ])]); - const previous = options.comparison.receipt; const now = new Date().toISOString(); await writeFile( join(options.staged.root, installReceiptFile), receiptDocument(createInstallReceipt({ ...options.receipt, directories, - hostDirectories: options.receipt.hostDirectories ?? previous?.hostDirectories ?? [], - installedAt: options.receipt.installedAt ?? previous?.installedAt ?? now, + hostDirectories: options.receipt.hostDirectories ?? previous.hostDirectories, + installedAt: options.receipt.installedAt ?? previous.installedAt, inventory: options.staged.inventory, updatedAt: options.receipt.updatedAt ?? now, })), diff --git a/packages/agent-bundle/src/install/state-root.ts b/packages/agent-bundle/src/install/state-root.ts index 813fdefe9..60dffb329 100644 --- a/packages/agent-bundle/src/install/state-root.ts +++ b/packages/agent-bundle/src/install/state-root.ts @@ -21,12 +21,6 @@ export interface InstalledStateRoot { readonly source: 'derived' | 'native'; } -/** Compatibility receipts authorize a derived purge only when they record that exact root. */ -export const isRecordedDerivedStateRoot = ( - recorded: InstalledStateRoot | undefined, - root: string, -): boolean => recorded?.source === 'derived' && recorded.root === root; - export interface InstalledStateLocation { readonly root?: string; readonly server: string; diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index 4144cbdaf..af5b1791f 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -9,13 +9,7 @@ import { } from '../adapters/capability-state.ts'; import portableCapabilityTable from '../adapters/capabilities/portable-1.0.0.json' with { type: 'json' }; import { sourceInputs, type TargetArtifactWrite } from '../adapters/types.ts'; -import { - installReceiptFile, - installReceiptFormat, - installRegistrationKinds, - installSurfaceMarkerFiles, - legacyInstallReceiptFormat, -} from './receipt.ts'; +import { installReceiptFile, installReceiptFormat, installRegistrationKinds } from './receipt.ts'; const marketplaceName = (model: NormalizedPlugin): string => `${model.metadata.name}-marketplace`; @@ -103,7 +97,7 @@ const claudeInstructions = (model: NormalizedPlugin): string[] => [ '```', '', 'Match the scope the plugin was installed with (`user`, `project`, or `local`). `--keep-data` keeps durable', - 'runtime state (Claude orphans the cached copy, `state/` included, for its ~14-day grace period); omit it to', + 'runtime state (Claude orphans the cached copy for its ~14-day grace period); omit it to', 'remove `~/.claude/plugins/data//` immediately.', '', ...marketplaceRemoval('claude', model), @@ -112,8 +106,8 @@ const claudeInstructions = (model: NormalizedPlugin): string[] => [ '(or under `$CLAUDE_CONFIG_DIR`; `user` is the install scope, pass `--scope project` or `--scope local` to match', '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 `state/` and `~/.claude/plugins/data//`', - 'immediately. A missing receipt or a cached copy that no longer matches it is refused unless `--force`; a', + '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.', '', ]; @@ -152,14 +146,14 @@ const codexInstructions = (model: NormalizedPlugin): string[] => [ `codex plugin remove ${pluginId(model)}`, '```', '', - 'Codex 0.147.0 deletes the cached plugin tree, `state/` included, on `plugin remove` and has no keep-data', - 'option.', + 'Codex 0.147.0 deletes the cached plugin tree on `plugin remove` and has no keep-data option.', '', ...marketplaceRemoval('codex', model), ...optionalCliUninstall('codex'), `\`agent-bundle install codex\` records a receipt at \`~/.codex/agent-bundle/receipts/${model.metadata.name}.${marketplaceName(model)}.user.json\` (or`, 'under `$CODEX_HOME`), and `uninstall` consumes it, running the two commands above in order. `--keep-data`', - 'cannot preserve durable state on Codex; the result says so (`unavailable`). A missing receipt or a cached', + '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.', '', ]; @@ -185,37 +179,38 @@ const cursorInstructions = (model: NormalizedPlugin): string[] => [ `The installer writes an install receipt (\`${installReceiptFile}\`: plugin, version, content hash,`, 'owned files) beside the plugin manifest. Re-running `node ./install.mjs` on an identical artifact', 'is a no-op that says so. When the installed copy has the same version but different content, the', - 'installer replaces its owned files in place and leaves runtime state (`state/`) untouched:', + 'installer replaces its owned files in place and leaves unowned entries untouched:', '', '```sh', 'node ./install.mjs # same-version content drift of a receipt-managed copy is replaced', - 'node ./install.mjs --replace # also replace a different installed version, or adopt a pre-receipt copy', + 'node ./install.mjs --replace # also replace a different installed version', '```', '', - 'A directory that is not an agent-bundle install of this plugin is always refused with an', - 'installed-versus-artifact content-hash comparison; remove it', - 'manually. The optional `agent-bundle` CLI applies the same policy through', + 'A directory without a receipt naming this plugin (including a copy placed before install receipts', + 'existed) is foreign and always refused with an installed-versus-artifact content-hash comparison;', + 'remove it manually and reinstall. The optional `agent-bundle` CLI applies the same policy through', '`agent-bundle install cursor --from ./ [--replace]`.', '', '### Uninstall', '', '```sh', 'node ./install.mjs --uninstall --plan # print exactly what would be removed', - 'node ./install.mjs --uninstall # remove the receipt-owned files; keep state/', - 'node ./install.mjs --uninstall --purge-data --confirm-purge # also remove durable runtime state', + 'node ./install.mjs --uninstall # remove the receipt-owned files; keep durable state', + 'node ./install.mjs --uninstall --purge-data --confirm-purge # also remove receipt-owned durable runtime state', 'node ./install.mjs --uninstall --mode marketplace # remove a staged marketplace repository', '```', '', 'Uninstall removes exactly what the receipt owns: the listed files, the directories the installer', `created (including \`~/.cursor/plugins/local\` when the installer made it), and nothing else. Durable`, - 'runtime state under `state/` (state kernel, notices journal) — and, for an Agent Plugins pack with a stdio', - 'server, the `~/.cursor/agent-bundle/plugin-data/` directory the receipt records as `PLUGIN_DATA` — is kept', - 'unless `--purge-data --confirm-purge` is passed (a kept data directory leaves a remnant receipt behind so a later', - 'purge still finds it; an empty one is pruned); unowned files are left in place and listed. A supported older', - 'receipt with no recorded state location retains the current environment\'s default as unproven; a keep-data run', - 'cannot turn that observation into later purge authority. A directory without a receipt is refused unless', - '`--force` (which removes a pre-receipt legacy copy by its inventory); owned content that no longer matches', - 'the receipt is refused unless `--force`; a directory that is not this plugin\'s install is always refused.', + 'runtime state (the framework state roots the receipt records with ownership evidence) — and, for an Agent', + 'Plugins pack with a stdio server, the `~/.cursor/agent-bundle/plugin-data/` directory the receipt records', + 'as `PLUGIN_DATA` — is kept unless `--purge-data --confirm-purge` is passed (a kept data directory leaves a', + 'remnant receipt behind so a later purge still finds it; an empty one is pruned); unowned entries, including a', + '`state/` directory beside the plugin, are left in place and listed. A receipt with no recorded state location', + '(written before the install could record it) retains the current environment\'s default as unproven; a', + 'keep-data run cannot turn that observation into later purge authority. A directory without a receipt naming', + 'this plugin is foreign and always refused, with or without `--force`; owned content that no longer matches', + 'the receipt is refused unless `--force`.', 'A second run is a `Not installed` no-op. With the optional `agent-bundle` CLI,', '`agent-bundle uninstall cursor --from ./ [--mode marketplace]` applies the same policy, and', '`agent-bundle doctor --from ./` shows the lifecycle stage (placed, registered, enabled, active) with', @@ -418,24 +413,24 @@ const portableInstructions = (planned: readonly string[]): string[] => [ '### Reinstall after a same-version rebuild', '', `The installer records an install receipt (\`${installReceiptFile}\`) and replaces its owned files in`, - 'place when the same version was rebuilt with different content; runtime state (`state/`) is never', - 'touched. Pass `--replace` to replace a different installed version or to adopt a', - 'copy installed before receipts existed. Foreign directories are refused with a content-hash', - 'comparison. For a client that manages its own copy, remove and re-add the plugin through that client', - 'when only content changed at the same version.', + 'place when the same version was rebuilt with different content; unowned entries are never', + 'touched. Pass `--replace` to replace a different installed version. A directory without a receipt', + 'naming this plugin (including a copy installed before receipts existed) is foreign and refused with a', + 'content-hash comparison; remove it manually. For a client that manages its own copy, remove and re-add', + 'the plugin through that client when only content changed at the same version.', '', '### Uninstall', '', '```sh', 'node ./install.mjs --uninstall --plan # print exactly what would be removed', - 'node ./install.mjs --uninstall # remove the receipt-owned files; keep state/', - 'node ./install.mjs --uninstall --purge-data --confirm-purge # also remove durable runtime state', + 'node ./install.mjs --uninstall # remove the receipt-owned files; keep durable state', + 'node ./install.mjs --uninstall --purge-data --confirm-purge # also remove receipt-owned durable runtime state', '```', '', 'Uninstall removes exactly what the receipt owns (files, installer-created directories) and keeps', - 'durable runtime state under `state/` (and the recorded `PLUGIN_DATA` directory of an Agent Plugins pack)', - 'unless `--purge-data --confirm-purge` is passed. A missing', - 'receipt or modified owned content is refused unless `--force`; foreign directories are always refused.', + 'the durable runtime state the receipt records (and the recorded `PLUGIN_DATA` directory of an Agent Plugins', + 'pack) unless `--purge-data --confirm-purge` is passed. Modified owned content is refused unless `--force`;', + 'a directory without a receipt naming this plugin is foreign and always refused.', '', ]; @@ -486,16 +481,16 @@ const installMarkdown = ( * `install/uninstall.ts` for the two Cursor deliveries: a receipt naming this * plugin bounds what leaves (owned files, installer-created directories, the * host directories it created), owned content must hash to the receipt unless - * `--force`, a pre-receipt legacy copy needs `--force`, foreign directories - * are refused regardless, `state/` and the receipt's recorded `PLUGIN_DATA` - * directory are kept unless `--purge-data --confirm-purge` (a kept data - * directory leaves a remnant receipt that records it), `--plan` prints the + * `--force`, a directory without such a receipt is foreign and refused + * regardless, the receipt's recorded state roots and `PLUGIN_DATA` directory + * are kept unless `--purge-data --confirm-purge` (a kept data directory + * leaves a remnant receipt that records it), `--plan` prints the * exact paths and writes nothing, and a second run is a * `Not installed` no-op. */ const cursorUninstallerSource = (): readonly string[] => [ - '// Unowned entries under root that survive the uninstall, POSIX-relative: files that are neither owned nor runtime state', - '// (symlinks listed, never followed) plus unowned directories holding nothing retained (`name/`), which the prune never touches.', + '// Unowned entries under root that survive the uninstall, POSIX-relative: files that are not owned (symlinks listed,', + '// never followed) plus unowned directories holding nothing retained (`name/`), which the prune never touches.', 'const listRetained = async (root, owned, ownedDirectories) => {', ' const retained = [];', " const visit = async (relativePath) => {", @@ -505,7 +500,7 @@ const cursorUninstallerSource = (): readonly string[] => [ ' let kept = 0;', ' for (const name of entries) {', " const child = relativePath === '' ? name : `${relativePath}/${name}`;", - " if (relativePath === '' && (name === receiptFile || isPreservedRoot(name))) continue;", + " if (relativePath === '' && name === receiptFile) continue;", ' const metadata = await lstat(join(root, child));', ' if (metadata.isDirectory() && !metadata.isSymbolicLink()) {', ' const below = await visit(child);', @@ -568,7 +563,7 @@ const cursorUninstallerSource = (): readonly string[] => [ " const webCanonical = resolve(destination);", " const webDigest = createHash('sha256').update(webCanonical).digest('hex').slice(0, 16);", " const webName = /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$/u.test(basename(webCanonical)) ? basename(webCanonical) : 'plugin';", - " return [explicitStateRoot ?? join(stateHome, segment), join(canonical, 'state'), join(homedir(), '.agent-bundle', 'web-data', `${webName}-${webDigest}`), explicitStateRoot === undefined ? 'derived' : 'native'];", + " return [explicitStateRoot ?? join(stateHome, segment), join(homedir(), '.agent-bundle', 'web-data', `${webName}-${webDigest}`)];", '};', '', "if (uninstall && mode === 'local') {", @@ -577,48 +572,29 @@ const cursorUninstallerSource = (): readonly string[] => [ ' const destinationMetadata = await lstat(destination);', " if (destinationMetadata.isSymbolicLink() || !destinationMetadata.isDirectory()) throw unsupported('.');", ' const receipt = await readReceipt(destination);', - ' let owned;', - ' let ownedDirectories;', - ' let hostDirectories;', - ' let receiptStatus;', ' if (receipt === undefined) {', - ' // No receipt: only a legacy layout (emitted install surface + manifest naming this plugin) may go, under --force.', ' const manifest = await readManifest(destination);', - ' const legacy = manifest?.name === pluginName && await hasMarkers(destination);', - ' if (!legacy) {', - ' throw new Error(`Refusing to uninstall foreign directory ${destination}: it carries no install receipt and is not a ` +', - ' `recognizable agent-bundle install of ${pluginName}${manifest === undefined ? " (no loader manifest)" : ` (manifest names ${JSON.stringify(manifest.name)})`}. ` +', - " 'Remove it manually if it is stale; --force does not apply to foreign directories.');", - ' }', + ' throw new Error(`Refusing to uninstall foreign directory ${destination}: it carries no install receipt naming ${pluginName}` +', + ' `${manifest === undefined ? " (no loader manifest)" : ` (manifest names ${JSON.stringify(manifest.name)})`}. ` +', + " 'Remove it manually if it is stale; --force does not apply to foreign directories.');", + ' }', + ' if (receipt.plugin !== pluginName) {', + ' throw new Error(`Refusing to uninstall ${destination}: its install receipt names plugin ${JSON.stringify(receipt.plugin)}, not ` +', + ' `${JSON.stringify(pluginName)}. Uninstall that plugin from its own bundle instead; --force does not apply.`);', + ' }', + ' const installedHash = await hashOwned(destination, receipt.files);', + " let receiptStatus = 'consumed';", + ' if (installedHash !== receipt.contentHash) {', ' if (!force) {', - ' throw new Error(`Refusing to uninstall ${destination} without an install receipt: this copy predates install receipts, so ownership ` +', - " 'cannot be proven. Re-run with --force to remove its inventoried plugin files (runtime state under state/ is kept unless ' +", - " '--purge-data --confirm-purge is passed), or reinstall with --replace first to adopt it.');", - ' }', - ' const tree = await inventory(destination);', - ' owned = tree.files;', - ' ownedDirectories = directoriesOf(tree.files);', - ' hostDirectories = [];', - " receiptStatus = 'forced-legacy';", - ' } else {', - ' if (receipt.plugin !== pluginName) {', - ' throw new Error(`Refusing to uninstall ${destination}: its install receipt names plugin ${JSON.stringify(receipt.plugin)}, not ` +', - ' `${JSON.stringify(pluginName)}. Uninstall that plugin from its own bundle instead; --force does not apply.`);', + ' throw new Error(`Refusing to uninstall ${destination}: the owned files hash ${short(installedHash)} but the receipt recorded ` +', + " `${short(receipt.contentHash)}, so the installed copy was modified after installation. Re-run with --force to remove the ` +", + " 'receipt-owned files anyway (unowned entries are never removed).');", ' }', - ' const installedHash = await hashOwned(destination, receipt.files);', - " receiptStatus = receipt.migratedFrom === undefined ? 'consumed' : 'migrated';", - ' if (installedHash !== receipt.contentHash) {', - ' if (!force) {', - ' throw new Error(`Refusing to uninstall ${destination}: the owned files hash ${short(installedHash)} but the receipt recorded ` +', - " `${short(receipt.contentHash)}, so the installed copy was modified after installation. Re-run with --force to remove the ` +", - " 'receipt-owned files anyway (unowned entries are never removed).');", - ' }', - " receiptStatus = 'forced-mismatch';", - ' }', - ' owned = receipt.files;', - ' ownedDirectories = receipt.directories;', - ' hostDirectories = receipt.hostDirectories;', + " receiptStatus = 'forced-mismatch';", ' }', + ' const owned = receipt.files;', + ' const ownedDirectories = receipt.directories;', + ' const hostDirectories = receipt.hostDirectories;', ' // A symlinked ancestor would let a leaf-only delete reach outside the plugin root: refused before any change.', ' await assertRealAncestors(destination, owned);', ' const files = [];', @@ -629,13 +605,13 @@ const cursorUninstallerSource = (): readonly string[] => [ ' if (metadata.isSymbolicLink() || !metadata.isFile()) throw unsupported(file);', ' files.push(path);', ' }', - ' if (await exists(join(destination, receiptFile))) files.push(join(destination, receiptFile));', - " const [resolvedStateDirectory, stateDirectory, resolvedWebDataDirectory, resolvedStateSource] = await runtimeStateRoots();", + ' files.push(join(destination, receiptFile));', + ' const [resolvedStateDirectory, resolvedWebDataDirectory] = await runtimeStateRoots();', ' const retainedState = [];', ' const ownedStatePaths = [];', ' const emptyOwnedStateFiles = [];', ' const emptyOwnedStateRoots = [];', - ' if (receipt?.state !== undefined) {', + ' 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; }", @@ -660,35 +636,25 @@ const cursorUninstallerSource = (): readonly string[] => [ ' ownedStatePaths.push(root.root);', ' }', ' } else {', - ' const fallbackStateDirectory = receipt?.stateRoot?.root ?? resolvedStateDirectory;', - " const fallbackStateSource = receipt?.stateRoot?.source ?? resolvedStateSource;", + ' // A receipt without a state block was written between the two receipt writes of an install: the observed root', + ' // is real but unproven, so it is retained until a reinstall records ownership.', ' let metadata;', - " try { metadata = await lstat(fallbackStateDirectory); } catch (error) { if (error?.code !== 'ENOENT') throw error; }", - " if (metadata?.isDirectory()) {", - " if (receipt?.stateRoot?.root === fallbackStateDirectory && fallbackStateSource === 'derived') ownedStatePaths.push(fallbackStateDirectory);", - " else retainedState.push({ path: fallbackStateDirectory, reason: 'unproven' });", - " }", + " try { metadata = await lstat(resolvedStateDirectory); } catch (error) { if (error?.code !== 'ENOENT') throw error; }", + " if (metadata?.isDirectory()) retainedState.push({ path: resolvedStateDirectory, reason: 'unproven' });", ' }', - ' const webDataDirectory = receipt?.webDataRoot ?? resolvedWebDataDirectory;', - ' 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; }", - ' if (metadata.isSymbolicLink() || !metadata.isDirectory()) throw unsupported(path);', - ' externalDataPaths.push(path);', + ' const webDataDirectory = receipt.webDataRoot ?? resolvedWebDataDirectory;', + ' const dataPaths = [...ownedStatePaths];', + ' let webDataMetadata;', + " try { webDataMetadata = await lstat(webDataDirectory); } catch (error) { if (error?.code !== 'ENOENT') throw error; }", + ' if (webDataMetadata !== undefined) {', + ' if (webDataMetadata.isSymbolicLink() || !webDataMetadata.isDirectory()) throw unsupported(webDataDirectory);', + ' dataPaths.push(webDataDirectory);', ' }', - ' let stateMetadata;', - " try { stateMetadata = await lstat(stateDirectory); } catch (error) { if (error?.code !== 'ENOENT') throw error; }", - " if (stateMetadata !== undefined && (stateMetadata.isSymbolicLink() || !stateMetadata.isDirectory())) throw unsupported('state');", - ' // 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) => ownedStatePaths.includes(path) ? `owned framework state root ${path}` : `web-data directory ${path}`), ...(stateMetadata === undefined || emptyState !== undefined ? [] : ['legacy state/ (state kernel, notices journal)'])];", + ' const dataKinds = dataPaths.map((path) => ownedStatePaths.includes(path) ? `owned framework state root ${path}` : `web-data directory ${path}`);', ' // 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.', - ' const recordedPluginData = receipt?.cursorExpansion?.pluginData;', + ' // purged like an owned state root), an empty one is an installer-created directory that is pruned, a recorded path elsewhere is left alone.', + ' const recordedPluginData = receipt.cursorExpansion?.pluginData;', ' const pluginDataRecorded = recordedPluginData === pluginData;', ' let emptyPluginData;', ' let foreignNote = "";', @@ -712,30 +678,29 @@ const cursorUninstallerSource = (): readonly string[] => [ " 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}`", + " ? `No durable runtime state exists${emptyPluginData === undefined ? '' : ` (the installer-created PLUGIN_DATA directory ${emptyPluginData} is empty and is pruned)`}.${foreignNote}`", ' : purgeData', " ? `${dataPaths.length === 0 ? 'No owned durable runtime state is removed.' : `Durable runtime state — ${dataKinds.join(' and ')} — is removed (--purge-data --confirm-purge).`}${retainedStateNote}${foreignNote}`", " : `Durable runtime state${dataKinds.length === 0 ? '' : ` — ${dataKinds.join(' and ')}`} — is kept; pass --purge-data --confirm-purge to remove owned roots.${retainedStateNote}${foreignNote}`;", ' // External state kept by --keep-data needs the remnant receipt and recorded ownership so a later purge', ' // removes the same root even though no plugin content remains.', - ' const keepRoot = !purgeData && [...dataPaths, ...retainedState.map((entry) => entry.path)].some((path) => path !== stateDirectory);', + ' const keepRoot = !purgeData && (dataPaths.length > 0 || retainedState.length > 0);', ' const directories = [', ' ...ownedDirectories.map((directory) => join(destination, directory)),', ' ...(keepRoot ? [] : [destination]),', ' ...hostDirectories.map((directory) => join(cursorRoot, directory)),', ' ...(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;', + ' const remnantOnly = receipt.files.length === 0 && receipt.registrations.length === 0;', ' const purging = purgeData && dataPaths.length > 0;', ' // 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.', + ' // no-op. Once the recorded roots 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 || 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', @@ -788,9 +753,9 @@ const cursorUninstallerSource = (): readonly string[] => [ ' // created host directories receipt-owned for a later purge and lets Doctor explain the directory; a reinstall fills it in.', " await writeReceiptFile(join(destination, receiptFile), receiptFor({ files: [], hash: createHash('sha256').digest('hex') }, {", ' // 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: [],', - ' ...(receipt?.state === undefined ? {} : { state: receipt.state }),', + ' ...(keepRoot && receipt.cursorExpansion !== undefined ? { cursorExpansion: receipt.cursorExpansion } : {}),', + ' directories: [], hostDirectories, installedAt: receipt.installedAt, registrations: [],', + ' ...(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.`);', @@ -809,7 +774,7 @@ const cursorUninstallerSource = (): readonly string[] => [ ' console.log(`Not installed ${pluginName}@${pluginVersion} for cursor (marketplace mode) at ${marketplaceRepo}`);', ' process.exit(0);', ' }', - " let receiptStatus = receipt === undefined ? 'forced-missing' : receipt.migratedFrom === undefined ? 'consumed' : 'migrated';", + " let receiptStatus = receipt === undefined ? 'forced-missing' : 'consumed';", " const recorded = receipt?.registrations.find((registration) => registration.kind === 'cursor-marketplace-staging');", ' if (repoExists) {', ' if (receipt === undefined) {', @@ -912,7 +877,7 @@ const cursorUninstallerSource = (): readonly string[] => [ * The standalone Cursor safe-copy installer. It carries no imports from * agent-bundle, so it mirrors the receipt policy of `install/receipt.ts` * line for line: same receipt file and format, same owned-file hashing, same - * ownership verdicts (receipt, legacy layout, foreign), and the same + * ownership verdicts (receipt, foreign), and the same * owned-files-only in-place replacement. `tests/install-surface.test.ts` and * the host-install proofs pin the two implementations to each other. */ @@ -932,11 +897,9 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { `const pluginVersion = ${version};`, `const receiptFile = ${JSON.stringify(installReceiptFile)};`, `const receiptFormat = ${JSON.stringify(installReceiptFormat)};`, - `const legacyReceiptFormat = ${JSON.stringify(legacyInstallReceiptFormat)};`, `const preservedEntries = ${JSON.stringify(preservedRuntimeEntries)};`, '// Runtime roots match case-insensitively: on case-insensitive filesystems State/ is state/.', 'const isPreservedRoot = (name) => preservedEntries.includes(String(name).toLowerCase());', - `const markerFiles = ${JSON.stringify(installSurfaceMarkerFiles)};`, "const source = resolve(fileURLToPath(new URL('.', import.meta.url)));", 'const artifactManifest = await (async () => {', ' try {', @@ -1178,9 +1141,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { " 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.', + '// Same shape check as the core reader: a receipt missing any field, or of any other format, reads as absent.', 'const readReceiptFile = async (path) => {', ' let value;', ' try {', @@ -1188,18 +1149,13 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { " value = JSON.parse(await readFile(path, 'utf8'));", " } catch (error) { if (error?.code === 'ENOENT' || error instanceof SyntaxError) return undefined; throw error; }", " if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined;", - ' if ((value.format !== receiptFormat && value.format !== legacyReceiptFormat) ||', + ' if (value.format !== receiptFormat ||', " typeof value.plugin !== 'string' || typeof value.version !== '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) {', - " return { ...value, format: receiptFormat, hostDirectories: [], migratedFrom: legacyReceiptFormat, mode: 'local',", - " registrations: [{ kind: 'cursor-local-plugin' }], scope: 'user', updatedAt: value.installedAt };", - ' }', " 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;', @@ -1238,11 +1194,6 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' return undefined;', '};', '', - 'const hasMarkers = async (root) => {', - ' for (const marker of markerFiles) if (!(await exists(join(root, marker)))) return false;', - ' return true;', - '};', - '', '// Agent Plugins packs (root plugin.json with an agent-plugins.org $schema, no .cursor-plugin/plugin.json).', '// Observed on Cursor 3.18.25 (docs/audits/2026-09-03-agent-plugins-cursor-ide-proof.md): the loader spawns their', '// stdio servers without expanding ${PLUGIN_ROOT}/${PLUGIN_DATA} in args, env values or cwd, without providing', @@ -1334,7 +1285,6 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { " 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,', ' ...(options.webDataRoot === undefined ? {} : { webDataRoot: options.webDataRoot }),', @@ -1706,18 +1656,15 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { 'const manifest = await readManifest(destination);', 'let ownership;', 'let installedHash;', - '// --uninstall --keep-data leaves a shell holding only state/: a reinstall fills it back in around the preserved', - '// durable state instead of refusing it as foreign (nothing in it is anyone\'s plugin content).', - 'const remnantEntries = (await readdir(destination)).filter((name) => name !== receiptFile);', - 'const stateOnlyRemnant = receipt === undefined', - ' ? remnantEntries.length > 0 && remnantEntries.every(isPreservedRoot)', - ' : receipt.plugin === pluginName && receipt.files.length === 0 && receipt.registrations.length === 0; // remnant receipt from --uninstall --keep-data', + '// --uninstall --keep-data leaves a remnant receipt owning no files around the preserved durable state: a reinstall', + '// fills the shell back in instead of refusing it.', + 'const remnant = receipt !== undefined && receipt.plugin === pluginName && receipt.files.length === 0 && receipt.registrations.length === 0;', 'if (receipt !== undefined && receipt.plugin === pluginName) {', " ownership = 'receipt';", ' installedHash = await hashOwned(destination, receipt.files);', '} else {', ' installedHash = (await inventory(destination)).hash;', - " ownership = stateOnlyRemnant || (receipt === undefined && manifest?.name === pluginName && await hasMarkers(destination)) ? 'legacy' : 'foreign';", + " ownership = 'foreign';", '}', "const installedVersion = manifest?.version ?? (ownership === 'receipt' ? receipt.version : undefined);", "const installedName = manifest?.name ?? (ownership === 'receipt' ? receipt.plugin : pluginName);", @@ -1738,52 +1685,22 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { "const inventoryMatches = ownership !== 'receipt' ||", ' (receipt.files.length === artifact.files.length && receipt.files.every((file, index) => file === artifact.files[index]));', 'if (installedHash === artifact.hash && inventoryMatches) {', - " if (ownership === 'legacy' && replace) {", - ' // 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);', - ' }', - " if (ownership === 'receipt' && receipt.migratedFrom !== undefined) {", - ' // An identical receipt-managed copy whose receipt predates the current format is upgraded in place:', - ' // lifecycle fields exactly as the reader synthesized them, and nothing else changes.', - ' await writeReceiptFile(join(destination, receiptFile), receiptFor(artifact, {', - ' directories: receipt.directories, hostDirectories: receipt.hostDirectories, installedAt: receipt.installedAt,', - ' }));', - ' }', - ' if (ownership === \'receipt\' && receipt.state === undefined) await attachStateOwnership();', + ' if (receipt.state === undefined) await attachStateOwnership();', ' console.log(`Already installed ${pluginName}@${pluginVersion} at ${destination} (content ${short(artifact.hash)})`);', ' process.exit(0);', '}', 'if (installedVersion !== undefined && installedVersion !== pluginVersion && !replace) {', ' throw new Error(`Refusing version collision at ${destination}: ${detail}. Re-run with --replace to replace this agent-bundle install.`);', '}', - "if (ownership === 'legacy' && !replace && !stateOnlyRemnant) {", - ' throw new Error(`Refusing content collision at ${destination}: ${detail}; this copy predates install receipts. ` +', - " 'Re-run with --replace once to adopt it; later same-version rebuilds replace automatically.');", - '}', '', '// Owned-files-only replacement: stale owned files leave first, staged files rename over their', '// predecessors, and the receipt lands last as the commit marker. Unowned entries (runtime state) stay.', 'const staged = await stage(artifact);', 'try {', - ' // A legacy copy has no inventory: only files the new artifact also ships count as owned; everything', - ' // else (operator files, stale artifact files, runtime state) stays in place and remains unowned.', ' const incoming = new Set(staged.inventory.files);', - ' let owned;', - " if (ownership === 'receipt') {", - ' owned = receipt.files;', - ' } else {', - ' owned = [];', - ' for (const file of (await inventory(destination)).files) {', - ' if (await isOwnedEntry(destination, incoming, file)) owned.push(file);', - ' }', - ' }', + ' const owned = receipt.files;', ' const ownedSet = new Set(owned);', - " const ownedDirectories = new Set(ownership === 'receipt' ? receipt.directories : []);", + ' const ownedDirectories = new Set(receipt.directories);', ' await assertRealAncestors(destination, owned);', ' await assertRealAncestors(destination, staged.inventory.files, ownedSet);', ' // An existing directory at an incoming file path is fine only when it is wholly owned: it and every', @@ -1833,13 +1750,12 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' // ones this replacement created; the first install time and the host directories it created carry', ' // over. Finalised in the private staging copy, then committed by rename.', ' const directories = sortNames(new Set([...[...ownedDirectories].filter((directory) => !pruned.has(directory)), ...created]));', - " const previous = ownership === 'receipt' ? receipt : undefined;", ' await writeFile(join(staged.root, receiptFile), receiptFor(staged.inventory, {', - ' directories, hostDirectories: previous?.hostDirectories ?? [], installedAt: previous?.installedAt,', + ' directories, hostDirectories: receipt.hostDirectories, installedAt: receipt.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)})`);', + ' await attachStateOwnership(receipt.state);', + ' if (remnant) 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();', '} finally {', diff --git a/packages/agent-bundle/src/install/uninstall.ts b/packages/agent-bundle/src/install/uninstall.ts index 6dd7acd7e..f0dd31d6b 100644 --- a/packages/agent-bundle/src/install/uninstall.ts +++ b/packages/agent-bundle/src/install/uninstall.ts @@ -7,7 +7,7 @@ import { Effect } from 'effect'; import { DiagnosticError } from '../core/diagnostics.ts'; import { errorMessage, isErrno } from '../core/errors.ts'; -import { exists, isPreservedRuntimeRoot } from '../core/paths.ts'; +import { exists } from '../core/paths.ts'; import { runPromise } from '../effect/boundary.ts'; import { liftPromise } from '../effect/lift.ts'; import { cacheHasPlugin, readHeadCommit } from './cursor-hooks-registration.ts'; @@ -40,12 +40,9 @@ import { installedBundleInventory, readBundleIdentity, type PluginIdentity } fro import { assertRealAncestors, createInstallReceipt, - directoriesOf, emptyContentHash, - hasInstallSurfaceMarkers, hashOwnedFiles, installReceiptFile, - installReceiptFormat, installReceiptStoreDirectory, isRemnantReceipt, listStoredInstallReceipts, @@ -55,7 +52,6 @@ import { removeStoredInstallReceipt, shortHash, simulateRemoveStoredInstallReceipt, - treeInventory, writeInstallReceipt, writeStoredInstallReceipt, type InstallReceipt, @@ -66,7 +62,6 @@ import { import { inspectInstalledStateOwnership, installedWebDataRoot, - isRecordedDerivedStateRoot, resolveInstalledStateRoot, } from './state-root.ts'; @@ -75,9 +70,9 @@ import { * `install`. Every mutation is opt-in, fail-closed, and bounded by what the * install receipt records — owned files and directories, the host * registrations the installer performed, and the host directories it created. - * Nothing outside that set is ever removed; durable runtime state (`state/`, - * the state kernel and notices journal) is kept unless `--purge-data` is - * confirmed explicitly. `--plan` computes the same result without opening a + * Nothing outside that set is ever removed; durable runtime state (the roots + * the receipt's `state` block records with ownership evidence) is kept unless + * `--purge-data` is confirmed explicitly. `--plan` computes the same result without opening a * writer, and a second run after a successful uninstall is a `not-installed` * no-op. */ @@ -87,9 +82,9 @@ export type UninstallDataPolicy = 'keep' | 'purge'; /** * What happened to the plugin's durable runtime state. `kept`/`purged`/`absent` * are Agent Bundle's own doing; `retained-by-host` (Claude keeps the orphaned - * cache copy, `state/` included, for its ~14-day grace period) and - * `removed-by-host` (Codex deletes the cached tree, `state/` included, on - * `plugin remove`) name the host behaviour that decided instead; `unavailable` + * cache copy for its ~14-day grace period) and `removed-by-host` (Codex + * deletes the cached tree on `plugin remove`) name the host behaviour that + * decided instead; `unavailable` * means the delivery has no Agent Bundle-owned runtime state (an Amp directory * plugin or a staged marketplace repository). */ @@ -127,19 +122,16 @@ export interface UninstallRegistrationReport extends InstallRegistration { /** * How the receipt drove this run: `consumed` (read, honoured, removed), - * `migrated` (a format/1 receipt read with synthesized lifecycle fields), - * `forced-missing`/`forced-legacy`/`forced-mismatch` (`--force` overrode a - * missing receipt, a pre-receipt legacy copy, or an owned-content hash - * mismatch), `missing` (nothing installed; no receipt to consume), `remnant` - * (a remnant receipt from an earlier `--keep-data` uninstall was found and - * left in place because only preserved state remains and it is being kept). + * `forced-missing`/`forced-mismatch` (`--force` overrode a host-registered + * copy with no store receipt, or an owned-content hash mismatch), `missing` + * (nothing installed; no receipt to consume), `remnant` (a remnant receipt + * from an earlier `--keep-data` uninstall was found and left in place because + * only retained entries remain and they are being kept). */ export type UninstallReceiptStatus = | 'consumed' - | 'forced-legacy' | 'forced-mismatch' | 'forced-missing' - | 'migrated' | 'missing' | 'remnant'; @@ -147,7 +139,6 @@ export interface UninstallReceiptReport { readonly contentHash?: string; readonly format?: string; readonly installedAt?: string; - readonly migratedFrom?: string; readonly path: string; readonly status: UninstallReceiptStatus; readonly version?: string; @@ -192,10 +183,9 @@ export interface UninstallBundleOptions { readonly confirmPurge?: boolean; readonly environment?: Readonly; /** - * Proceed without a receipt (a pre-receipt legacy copy, or a host-registered - * copy with no store receipt) or when owned content no longer matches the - * receipt. Foreign directories — a receipt or manifest naming another plugin - * — are refused regardless. + * 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. */ readonly force?: boolean; readonly from: string; @@ -254,9 +244,8 @@ const receiptReport = (path: string, receipt: InstallReceipt | undefined, status Object.freeze({ ...(receipt === undefined ? {} : { contentHash: receipt.contentHash, - format: receipt.migratedFrom ?? installReceiptFormat, + format: receipt.format, installedAt: receipt.installedAt, - ...(receipt.migratedFrom === undefined ? {} : { migratedFrom: receipt.migratedFrom }), version: receipt.version, }), path, @@ -306,9 +295,9 @@ const wouldPrune = async (path: string, gone: Set): Promise => /** * Unowned entries under `root` that survive the uninstall, POSIX-relative: regular files (and symlinks, listed - * but never followed) that are not owned and not runtime state, plus unowned directories holding nothing - * retained (`name/`), which the prune never touches because only owned directories are candidates. A directory - * that holds a retained entry is implied by that entry and is not listed itself. + * but never followed) that are not owned, plus unowned directories holding nothing retained (`name/`), which + * the prune never touches because only owned directories are candidates. A directory that holds a retained + * entry is implied by that entry and is not listed itself. */ const listRetained = async ( root: string, @@ -327,7 +316,7 @@ const listRetained = async ( let kept = 0; for (const name of entries) { const child = relativePath === '' ? name : `${relativePath}/${name}`; - if (relativePath === '' && (name === installReceiptFile || isPreservedRuntimeRoot(name))) continue; + if (relativePath === '' && name === installReceiptFile) continue; const metadata = await lstat(join(root, child)); if (metadata.isDirectory() && !metadata.isSymbolicLink()) { const below = await visit(child); @@ -350,17 +339,15 @@ interface CursorLocalOwnership { readonly directories: readonly string[]; readonly files: readonly string[]; readonly hostDirectories: readonly string[]; - readonly receipt: InstallReceipt | undefined; + readonly receipt: InstallReceipt; readonly status: UninstallReceiptStatus; } /** * Decides what a Cursor local uninstall may remove. A receipt naming this * plugin owns exactly its files and directories; its owned content must hash - * to the recorded content hash unless `--force`. Without a receipt, only a - * legacy layout (emitted install surface plus a manifest naming this plugin) - * may be removed, and only under `--force`, by its inventory. Anything else is - * foreign and is refused with or without `--force`. + * to the recorded content hash unless `--force`. A directory without such a + * receipt is foreign and is refused with or without `--force`. */ const cursorLocalOwnership = async ( destination: string, @@ -370,34 +357,13 @@ const cursorLocalOwnership = async ( const receipt = await readInstallReceipt(destination); if (receipt === undefined) { const manifest = await readInstalledManifest(destination); - const legacy = manifest?.name === identity.plugin && await hasInstallSurfaceMarkers(destination); - if (!legacy) { - throw failure( - 'AB7007', - `Refusing to uninstall foreign directory ${destination}: it carries no install receipt and is not a ` + - `recognizable agent-bundle install of ${identity.plugin}` + - `${manifest === undefined ? ' (no loader manifest)' : ` (manifest names ${JSON.stringify(manifest.name)})`}. ` + - 'Remove it manually if it is stale; --force does not apply to foreign directories.', - 'cursor', - ); - } - if (!force) { - throw failure( - 'AB7009', - `Refusing to uninstall ${destination} without an install receipt: this copy predates install receipts, so ` + - 'ownership cannot be proven. Re-run with --force to remove its inventoried plugin files (runtime state ' + - 'under state/ is kept unless --purge-data --confirm-purge is passed), or reinstall with --replace first to adopt it.', - 'cursor', - ); - } - const inventory = await treeInventory(destination); - return { - directories: directoriesOf(inventory.files), - files: inventory.files, - hostDirectories: [], - receipt: undefined, - status: 'forced-legacy', - }; + throw failure( + 'AB7007', + `Refusing to uninstall foreign directory ${destination}: it carries no install receipt naming ${identity.plugin}` + + `${manifest === undefined ? ' (no loader manifest)' : ` (manifest names ${JSON.stringify(manifest.name)})`}. ` + + 'Remove it manually if it is stale; --force does not apply to foreign directories.', + 'cursor', + ); } if (receipt.plugin !== identity.plugin) { throw failure( @@ -408,7 +374,7 @@ const cursorLocalOwnership = async ( ); } const installedContentHash = await hashOwnedFiles(destination, receipt.files); - let status: UninstallReceiptStatus = receipt.migratedFrom === undefined ? 'consumed' : 'migrated'; + let status: UninstallReceiptStatus = 'consumed'; if (installedContentHash !== receipt.contentHash) { if (!force) { throw failure( @@ -449,8 +415,6 @@ const realPluginDataDirectory = async (cursorRoot: string, pluginData: string): interface CursorLocalData { /** Installer-created `PLUGIN_DATA` directory that nothing wrote to: pruned like a created host directory, never "data". */ 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. */ @@ -462,23 +426,20 @@ interface CursorLocalData { const cursorLocalData = async ( destination: string, policy: UninstallDataPolicy, - receipt: InstallReceipt | undefined, + receipt: InstallReceipt, cursorRoot: string, plugin: string, environment: Readonly, home: string, ): Promise => { - const stateDirectory = join(destination, 'state'); - const webData = receipt?.webDataRoot ?? installedWebDataRoot(destination, 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 (receipt?.state !== undefined) { + 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); @@ -491,22 +452,11 @@ const cursorLocalData = async ( } } } else { - const observed = receipt?.stateRoot ?? await resolveInstalledStateRoot(destination, 'cursor', environment, home); - if (observed.root !== stateDirectory && await realDirectory(observed.root, 'cursor') !== undefined) { - if (isRecordedDerivedStateRoot(receipt?.stateRoot, observed.root)) { - 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) { - emptyState = stateDirectory; - } else { - paths.push(stateDirectory); - kinds.push('state/ (state kernel, notices journal)'); + // A receipt without a `state` block was written between the two receipt writes of a Cursor install: the + // observed root is real but unproven, so it is retained until a reinstall records ownership. + const observed = await resolveInstalledStateRoot(destination, 'cursor', environment, home); + if (await realDirectory(observed.root, 'cursor') !== undefined) { + retainedState.push({ path: observed.root, reason: 'unproven' }); } } if (await realDirectory(webData, 'cursor') !== undefined) { @@ -515,7 +465,7 @@ const cursorLocalData = async ( } // The receipt's cursorExpansion records the PLUGIN_DATA directory the installer created for this copy; only the // directory at this home's own plugin-data location is receipt-owned — a recorded path elsewhere is left alone. - const recorded = receipt?.cursorExpansion?.pluginData; + const recorded = receipt.cursorExpansion?.pluginData; const expected = cursorPluginDataDirectory(cursorRoot, plugin); let emptyPluginData: string | undefined; let foreignPluginData: string | undefined; @@ -537,16 +487,13 @@ const cursorLocalData = async ( 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 (${ - 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}`, + detail: `No durable runtime state exists${ + emptyPluginData === undefined ? '' : ` (the installer-created PLUGIN_DATA directory ${emptyPluginData} is empty and is pruned)` + }.${foreignNote}`, outcome: 'absent', paths: Object.freeze([]), policy, @@ -559,7 +506,6 @@ const cursorLocalData = async ( : ` 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, @@ -641,14 +587,11 @@ 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); + files.push(...data.emptyStateFiles, receiptPath); // External state kept by --keep-data needs the remnant receipt and recorded ownership so a later purge can // remove the same root even though no plugin content remains. - const keepRoot = policy === 'keep' && - [...data.report.paths, ...(data.report.retained ?? []).map((entry) => entry.path)] - .some((path) => path !== join(destination, 'state')); - const pluginDataRecorded = ownership.receipt?.cursorExpansion?.pluginData === cursorPluginDataDirectory(cursorRoot, identity.plugin); + const keepRoot = policy === 'keep' && (data.report.paths.length > 0 || (data.report.retained ?? []).length > 0); + const pluginDataRecorded = ownership.receipt.cursorExpansion?.pluginData === cursorPluginDataDirectory(cursorRoot, identity.plugin); const directoryCandidates = [ ...ownership.directories.map((directory) => join(destination, directory)), ...(keepRoot ? [] : [destination]), @@ -656,20 +599,19 @@ const uninstallCursorLocal = async ( // The installer created PLUGIN_DATA and its agent-bundle parents; once empty they go too — never while // 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); const retained = await listRetained(destination, owned, ownedDirectories); - const remnantOnly = ownership.receipt !== undefined && isRemnantReceipt(ownership.receipt); + const remnantOnly = isRemnantReceipt(ownership.receipt); const purging = data.present && policy === 'purge'; if (remnantOnly && policy !== 'purge' && (data.present || retained.length > 0) && files.length === 1 && files[0] === receiptPath) { // A rerun over what an earlier `--keep-data` uninstall left behind, still keeping data that is still there // (or unowned entries that keep the root alive): nothing to remove, so the remnant receipt stays in place and - // the run is the documented no-op. Once the preserved state is gone — state/ or the PLUGIN_DATA directory - // removed or emptied by hand — the remnant guards nothing, and the rerun below consumes it (receipt, empty - // plugin root, the host and plugin-data directories it recorded) like an explicit purge would. + // the run is the documented no-op. Once the preserved state is gone — the recorded roots or the PLUGIN_DATA + // directory removed or emptied by hand — the remnant guards nothing, and the rerun below consumes it + // (receipt, empty plugin root, the host and plugin-data directories it recorded) like an explicit purge would. return Object.freeze({ ...base, data: data.report, @@ -726,22 +668,22 @@ const uninstallCursorLocal = async ( // directory instead of calling it corrupt. A reinstall fills it back in as an `installed`. await writeInstallReceipt(destination, createInstallReceipt({ // A kept PLUGIN_DATA directory stays receipt-owned through the remnant's expansion record. - ...(ownership.receipt?.cursorExpansion === undefined || + ...(ownership.receipt.cursorExpansion === undefined || !data.report.paths.includes(ownership.receipt.cursorExpansion.pluginData) ? {} : { cursorExpansion: ownership.receipt.cursorExpansion }), host: 'cursor', hostDirectories: ownership.hostDirectories, - ...(ownership.receipt === undefined ? {} : { installedAt: ownership.receipt.installedAt }), + installedAt: ownership.receipt.installedAt, inventory: { files: [], hash: emptyContentHash }, mode: 'local', plugin: identity.plugin, registrations: [], scope: 'user', - ...(ownership.receipt?.state === undefined ? {} : { state: ownership.receipt.state }), + ...(ownership.receipt.state === undefined ? {} : { state: ownership.receipt.state }), ...(keepRoot ? { webDataRoot: data.webDataRoot } : {}), updatedAt: new Date().toISOString(), - version: ownership.receipt?.version ?? identity.version, + version: ownership.receipt.version, })); remnantReceipt = receiptPath; } @@ -848,9 +790,7 @@ const uninstallCursorMarketplace = async ( state: 'not-installed', }); } - let status: UninstallReceiptStatus = receipt === undefined - ? 'forced-missing' - : receipt.migratedFrom === undefined ? 'consumed' : 'migrated'; + let status: UninstallReceiptStatus = receipt === undefined ? 'forced-missing' : 'consumed'; const recorded = receipt?.registrations.find((registration) => registration.kind === 'cursor-marketplace-staging'); if (repo !== undefined) { if (receipt === undefined) { @@ -1173,14 +1113,8 @@ const publicHostData = async ( } } if (entry !== undefined) { - const legacyStateRoot = join(entry.installPath, 'state'); - const candidates = [ - ...(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); - } + const webDataRoot = installedWebDataRoot(entry.installPath, home); + if (!paths.includes(webDataRoot) && await realDirectory(webDataRoot, host) !== undefined) paths.push(webDataRoot); if (receipt?.state === undefined) { const observed = await resolveInstalledStateRoot(entry.installPath, host, environment, home); if ( @@ -1246,7 +1180,7 @@ const publicHostData = async ( : ` 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.' + ? '`claude plugin uninstall --keep-data` orphans the cached copy for Claude\'s ~14-day grace period; Agent Bundle preserves the effective framework state root, 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' @@ -1330,9 +1264,7 @@ const uninstallPublicCli = async ( state: 'not-installed', }); } - let status: UninstallReceiptStatus = receipt === undefined - ? 'forced-missing' - : receipt.migratedFrom === undefined ? 'consumed' : 'migrated'; + let status: UninstallReceiptStatus = receipt === undefined ? 'forced-missing' : 'consumed'; if (entry !== undefined) { if (receipt === undefined) { if (!force) { @@ -1340,7 +1272,7 @@ const uninstallPublicCli = async ( 'AB7009', `Refusing to uninstall ${id} from ${host}${entry.scope === undefined ? '' : ` (scope ${entry.scope})`}: the host ` + `reports it installed at ${entry.installPath} but no agent-bundle receipt exists at ${receiptPath}, so this ` + - 'install was not made by agent-bundle or predates lifecycle receipts. ' + + 'install was not made by agent-bundle. ' + `Re-run with --force to uninstall through \`${host} plugin ${host === 'claude' ? 'uninstall' : 'remove'}\` anyway.`, host, ); @@ -1612,9 +1544,7 @@ const uninstallAmp = async ( ); } const installedHash = await hashOwnedFiles(location.destination, receipt.files); - const status: UninstallReceiptStatus = installedHash === receipt.contentHash - ? receipt.migratedFrom === undefined ? 'consumed' : 'migrated' - : 'forced-mismatch'; + const status: UninstallReceiptStatus = installedHash === receipt.contentHash ? 'consumed' : 'forced-mismatch'; if (installedHash !== receipt.contentHash && !force) { throw failure( 'AB7007', diff --git a/packages/agent-bundle/tests/doctor.test.ts b/packages/agent-bundle/tests/doctor.test.ts index 741e0cc3e..dc7d2d726 100644 --- a/packages/agent-bundle/tests/doctor.test.ts +++ b/packages/agent-bundle/tests/doctor.test.ts @@ -122,7 +122,7 @@ const createBundle = async ( ]); } else { // Every emitted Cursor-compatible bundle carries the install surface; a receipt-less copy of it - // is a legacy agent-bundle install rather than a foreign directory. + // is still foreign, since only a receipt proves ownership. await Promise.all([ writeJson(join(bundle, '.cursor-plugin/plugin.json'), { name: 'doctor-fixture', version }), writeFile(join(bundle, 'INSTALL.md'), '# Install doctor-fixture\n'), @@ -518,7 +518,6 @@ it('inventories durable SQLite stores and sidecars without opening them', async const pluginRoot = join(fixture.home, '.cursor', 'plugins', 'local', 'stateful'); const environment = { XDG_STATE_HOME: join(fixture.root, 'state-home') }; const stateRoot = userDataStateRoot(pluginRoot, environment, fixture.home); - const legacyStateRoot = join(pluginRoot, 'state'); const store = 'project-tasks-0123456789abcdef.sqlite'; try { await Promise.all([ @@ -527,7 +526,7 @@ it('inventories durable SQLite stores and sidecars without opening them', async { name: 'stateful', version: '1.0.0' }, ), mkdir(stateRoot, { recursive: true }), - mkdir(legacyStateRoot, { recursive: true }), + mkdir(join(pluginRoot, 'state'), { recursive: true }), ]); await Promise.all([ writeFile(join(stateRoot, store), 'database'), @@ -561,12 +560,9 @@ it('inventories durable SQLite stores and sidecars without opening them', async servers: ['default'], writable: true, }); - const legacyDiagnostic = report.diagnostics.find((entry) => entry.code === 'AB7332'); - expect(legacyDiagnostic).toMatchObject({ - message: expect.stringContaining(legacyStateRoot), - recovery: expect.stringContaining('retains the unrecorded effective root'), - }); - expect(legacyDiagnostic?.recovery).not.toContain('both roots'); + // An in-tree state/ is an ordinary unowned entry: no durable-state finding and no diagnostic names it. + expect(finding?.durableStates).toEqual([finding?.durableState]); + expect(report.diagnostics.some((entry) => entry.message.includes(join(pluginRoot, 'state')))).toBe(false); const human = captureCliTerminal(); const humanCode = await runCli(['doctor'], human.output, { runDoctor: async () => report }); @@ -588,7 +584,7 @@ it('inventories durable SQLite stores and sidecars without opening them', async } }); -it('reports a current-environment legacy state root as unrecorded and retained', async () => { +it('reports the current-environment state root of a receipt without a state block as unrecorded and retained', async () => { const fixture = await temporaryDoctor(); const originalEnvironment = { XDG_STATE_HOME: join(fixture.root, 'original-state-home') }; const currentEnvironment = { XDG_STATE_HOME: join(fixture.root, 'current-state-home') }; @@ -610,20 +606,8 @@ it('reports a current-environment legacy state root as unrecorded and retained', await writeFile(join(currentStateRoot, 'unrelated.txt'), 'unrelated\n'); const receiptPath = join(destination, installReceiptFile); const receipt = JSON.parse(await readFile(receiptPath, 'utf8')) as Record; - const { - hostDirectories: _hostDirectories, - mode: _mode, - registrations: _registrations, - scope: _scope, - state: _state, - stateRoot: _stateRoot, - updatedAt: _updatedAt, - ...legacy - } = receipt; - await writeFile(receiptPath, JSON.stringify({ - ...legacy, - format: 'agent-bundle-install-receipt/1', - })); + const { state: _state, ...withoutState } = receipt; + await writeFile(receiptPath, JSON.stringify(withoutState)); const report = await runDoctor({ endpointDirectory: fixture.endpointDirectory, @@ -660,37 +644,7 @@ it('reports a current-environment legacy state root as unrecorded and retained', paths: [], retained: [{ path: currentStateRoot, reason: 'unproven' }], }); - - await mkdir(join(destination, 'state')); - await writeFile(join(destination, 'state', 'legacy.sqlite'), 'legacy\n'); - await writeFile(receiptPath, JSON.stringify({ - ...legacy, - format: 'agent-bundle-install-receipt/1', - stateRoot: { root: originalStateRoot, source: 'derived' }, - })); - const recordedReport = await runDoctor({ - endpointDirectory: fixture.endpointDirectory, - environment: currentEnvironment, - home: fixture.home, - hosts: ['cursor'], - }); - const recordedFinding = hostReport(recordedReport, 'cursor').inventory.findings.find( - (entry) => entry.entry === 'doctor-fixture', - ); - expect(recordedFinding?.durableStates).toEqual(expect.arrayContaining([ - expect.objectContaining({ - directory: currentStateRoot, - ownership: 'unrecorded', - purgeable: false, - }), - expect.objectContaining({ - directory: originalStateRoot, - ownership: 'derived', - purgeable: true, - }), - ])); - expect(recordedReport.diagnostics.find((entry) => entry.code === 'AB7332')?.recovery) - .toContain('receipt-owned effective roots'); + expect(await readFile(join(originalStateRoot, 'state.sqlite'), 'utf8')).toBe('original\n'); } finally { await fixture.cleanup(); } @@ -884,53 +838,6 @@ it('prints a web surface line when the bundle manifest exposes Apps', async () = } }); -it('inventories durable state under a checked --from bundle', async () => { - const fixture = await temporaryDoctor(); - try { - const bundle = await createBundle(fixture.root, 'codex'); - const stateRoot = join(bundle, 'state'); - await mkdir(stateRoot); - await writeFile(join(stateRoot, 'from-bundle-fedcba9876543210.sqlite'), 'state'); - const report = await runDoctor({ - commandRunner: versionRunner, - endpointDirectory: fixture.endpointDirectory, - from: bundle, - home: fixture.home, - hosts: ['codex'], - }); - expect(hostReport(report, 'codex').bundle?.durableState).toMatchObject({ - directory: stateRoot, - findings: [{ bytes: 5, file: 'from-bundle-fedcba9876543210.sqlite' }], - summary: { bytes: 5, stores: 1 }, - }); - } finally { - await fixture.cleanup(); - } -}); - -it('warns when an installed bundle state directory cannot be read', async () => { - const fixture = await temporaryDoctor(); - const pluginRoot = join(fixture.home, '.cursor', 'plugins', 'local', 'blocked-state'); - try { - await writeJson( - join(pluginRoot, '.cursor-plugin/plugin.json'), - { name: 'blocked-state', version: '1.0.0' }, - ); - await writeFile(join(pluginRoot, 'state'), 'not a directory'); - const report = await runDoctor({ - endpointDirectory: fixture.endpointDirectory, - home: fixture.home, - hosts: ['cursor'], - }); - expect(report.diagnostics).toEqual(expect.arrayContaining([ - expect.objectContaining({ code: 'AB7316', severity: 'warning' }), - ])); - expect(report.summary).toMatchObject({ errors: 0, warnings: 1 }); - } finally { - await fixture.cleanup(); - } -}); - it('reports a Cursor inventory manifest with a non-string version as corrupt', async () => { const fixture = await temporaryDoctor(); const installRoot = join(fixture.home, '.cursor', 'plugins', 'local'); @@ -986,13 +893,43 @@ it('reports corrupt, symlinked, and interrupted Cursor inventory entries', async } }); +it('warns when an installed bundle state root cannot be read', async () => { + const fixture = await temporaryDoctor(); + const pluginRoot = join(fixture.home, '.cursor', 'plugins', 'local', 'blocked-state'); + const environment = { XDG_STATE_HOME: join(fixture.root, 'state-home') }; + const stateRoot = userDataStateRoot(pluginRoot, environment, fixture.home); + try { + await writeJson( + join(pluginRoot, '.cursor-plugin/plugin.json'), + { name: 'blocked-state', version: '1.0.0' }, + ); + await mkdir(dirname(stateRoot), { recursive: true }); + await writeFile(stateRoot, 'not a directory'); + const report = await runDoctor({ + endpointDirectory: fixture.endpointDirectory, + environment, + home: fixture.home, + hosts: ['cursor'], + }); + expect(report.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: 'AB7316', + message: `Durable state directory ${JSON.stringify(stateRoot)} could not be read.`, + severity: 'warning', + }), + ])); + expect(report.summary).toMatchObject({ errors: 0, warnings: 1 }); + } finally { + await fixture.cleanup(); + } +}); + it('accepts valid installed and --from Cursor bytes without static findings', async () => { const fixture = await temporaryDoctor(); try { const bundle = await createBundle(fixture.root, 'cursor'); - const destination = join(fixture.home, '.cursor', 'plugins', 'local', 'doctor-fixture'); - await mkdir(dirname(destination), { recursive: true }); - await cp(bundle, destination, { recursive: true }); + await mkdir(join(fixture.home, '.cursor'), { recursive: true }); + await installBundle({ from: bundle, home: fixture.home, host: 'cursor' }); const report = await runDoctor({ endpointDirectory: fixture.endpointDirectory, @@ -1540,8 +1477,8 @@ it('classifies Cursor bundle state as installed, missing, drifted, or conflicted try { const bundle = await createBundle(fixture.root, 'cursor'); const destination = join(fixture.home, '.cursor', 'plugins', 'local', 'doctor-fixture'); - await mkdir(dirname(destination), { recursive: true }); - await cp(bundle, destination, { recursive: true }); + await mkdir(join(fixture.home, '.cursor'), { recursive: true }); + await installBundle({ from: bundle, home: fixture.home, host: 'cursor' }); await testCase.mutate(destination); const report = await runDoctor({ endpointDirectory: fixture.endpointDirectory, @@ -1619,13 +1556,14 @@ it('compares the installed Cursor copy against the artifact: current, stale, for expect(staleDiagnostic?.message).toContain(`content ${rebuiltHash.slice(0, 12)}`); expect(staleDiagnostic?.recovery).toContain('replaced automatically'); - // Legacy pre-receipt copy with different content: stale, recovery points at --replace. + // A copy without a receipt is foreign even when it carries the install surface and names the plugin. await removeTree(destination); await cp(bundle, destination, { recursive: true }); await writeFile(join(destination, 'payload.txt'), 'older\n'); - const legacy = hostReport(await doctor(), 'cursor'); - expect(legacy.bundle).toMatchObject({ comparison: { ownership: 'legacy', status: 'stale' }, state: 'drifted' }); - expect(legacy.diagnostics.find((entry) => entry.code === 'AB7308')?.recovery).toContain('--replace'); + const preReceipt = hostReport(await doctor(), 'cursor'); + expect(preReceipt.bundle).toMatchObject({ comparison: { ownership: 'foreign', status: 'foreign' }, state: 'conflicted' }); + expect(preReceipt.bundle).not.toHaveProperty('receipt'); + expect(preReceipt.diagnostics.find((entry) => entry.code === 'AB7321')?.recovery).toContain('Remove the foreign directory manually'); // Foreign directory under the plugin name: no receipt, no install surface. await removeTree(destination); @@ -1659,8 +1597,8 @@ it('treats a versionless Cursor destination as drifted rather than conflicted', try { const bundle = await createBundle(fixture.root, 'cursor'); const destination = join(fixture.home, '.cursor', 'plugins', 'local', 'doctor-fixture'); - await mkdir(dirname(destination), { recursive: true }); - await cp(bundle, destination, { recursive: true }); + await mkdir(join(fixture.home, '.cursor'), { recursive: true }); + await installBundle({ from: bundle, home: fixture.home, host: 'cursor' }); await writeJson(join(destination, '.cursor-plugin/plugin.json'), { name: 'doctor-fixture' }); const report = await runDoctor({ endpointDirectory: fixture.endpointDirectory, @@ -2325,7 +2263,7 @@ it('surfaces the placed → registered → enabled → active lifecycle per host } }); -it('inventories store receipts, diagnoses orphaned ones (AB7328), and reports pre-lifecycle receipts as migrated (AB7329)', async () => { +it('inventories store receipts, diagnoses orphaned ones (AB7328), and treats a format/1 receipt as no receipt', async () => { const fixture = await temporaryDoctor(); try { const bundle = await createBundle(fixture.root, 'claude'); @@ -2420,28 +2358,28 @@ it('inventories store receipts, diagnoses orphaned ones (AB7328), and reports pr expect(unprobed.diagnostics.filter((entry) => entry.code === 'AB7328').some((entry) => entry.message.includes('not a valid install receipt'))).toBe(true); await rm(join(claudeConfig, 'agent-bundle', 'receipts', 'broken.user.json')); - // A Cursor local copy whose receipt predates format/2 is diagnosed as migrated, never rewritten by Doctor. + // A Cursor local copy whose receipt is format/1 has no readable receipt: the copy is foreign and Doctor + // never rewrites the file. const cursorBundle = await createBundle(fixture.root, 'cursor'); await mkdir(join(fixture.home, '.cursor'), { recursive: true }); await installBundle({ from: cursorBundle, home: fixture.home, host: 'cursor' }); const destination = join(fixture.home, '.cursor', 'plugins', 'local', 'doctor-fixture'); const receiptPath = join(destination, '.agent-bundle-install.json'); const written = JSON.parse(await readFile(receiptPath, 'utf8')) as Record; - const { hostDirectories: _h, mode: _m, registrations: _r, scope: _s, updatedAt: _u, ...legacy } = written; - await writeFile(receiptPath, JSON.stringify({ ...legacy, format: 'agent-bundle-install-receipt/1' })); - const migrated = hostReport(await runDoctor({ + const { hostDirectories: _h, mode: _m, registrations: _r, scope: _s, updatedAt: _u, ...formatOne } = written; + const formatOneText = JSON.stringify({ ...formatOne, format: 'agent-bundle-install-receipt/1' }); + await writeFile(receiptPath, formatOneText); + const foreign = hostReport(await runDoctor({ endpointDirectory: fixture.endpointDirectory, from: cursorBundle, home: fixture.home, hosts: ['cursor'], }), 'cursor'); - expect(migrated.inventory.findings[0]?.receipt).toMatchObject({ format: 'agent-bundle-install-receipt/1', migratedFrom: 'agent-bundle-install-receipt/1' }); - expect(migrated.bundle).toMatchObject({ receipt: { migratedFrom: 'agent-bundle-install-receipt/1' }, state: 'installed' }); - const migration = migrated.diagnostics.filter((entry) => entry.code === 'AB7329'); - expect(migration).toHaveLength(1); - expect(migration[0]).toMatchObject({ severity: 'info', target: 'cursor' }); - expect(migration[0]?.recovery).toContain('agent-bundle-install-receipt/2'); - expect(await readFile(receiptPath, 'utf8')).toContain('agent-bundle-install-receipt/1'); + expect(foreign.inventory.findings[0]).not.toHaveProperty('receipt'); + expect(foreign.bundle).toMatchObject({ comparison: { ownership: 'foreign', status: 'foreign' }, state: 'conflicted' }); + expect(foreign.diagnostics.find((entry) => entry.code === 'AB7321')?.message).toContain(`Cursor destination ${destination} is a foreign install`); + expect(foreign.diagnostics.some((entry) => entry.code === 'AB7329')).toBe(false); + expect(await readFile(receiptPath, 'utf8')).toBe(formatOneText); } finally { await fixture.cleanup(); } @@ -2602,7 +2540,7 @@ it('cross-checks Claude project-scope receipts from their recorded project root, } }); -it('explains a Cursor directory holding only preserved runtime state instead of calling it corrupt or foreign', async () => { +it('explains a Cursor directory left by uninstall --keep-data through its remnant receipt, and calls a receipt-less one corrupt and foreign', async () => { const fixture = await temporaryDoctor(); try { const bundle = await createBundle(fixture.root, 'cursor'); @@ -2612,14 +2550,18 @@ it('explains a Cursor directory holding only preserved runtime state instead of await writeFile(join(destination, 'state', 'plugin.sqlite'), 'durable\n'); const doctor = () => runDoctor({ endpointDirectory: fixture.endpointDirectory, from: bundle, home: fixture.home, hosts: ['cursor'] }); - // Without any receipt (a hand-cleaned directory), the state-only shell is still not foreign. + // Without any receipt (a hand-cleaned directory), nothing proves the shell is ours: corrupt entry, foreign destination. const bare = hostReport(await doctor(), 'cursor'); - expect(bare.inventory.findings).toEqual([expect.objectContaining({ path: destination, state: 'missing' })]); - expect(bare.bundle).toMatchObject({ comparison: { status: 'not-installed' }, state: 'missing' }); - expect(bare.diagnostics.filter((entry) => entry.severity !== 'info')).toEqual([]); - expect(bare.diagnostics.filter((entry) => entry.code === 'AB7307').every((entry) => entry.message.includes('preserved runtime state'))).toBe(true); + expect(bare.inventory.findings).toEqual([expect.objectContaining({ path: destination, state: 'corrupt' })]); + expect(bare.bundle).toMatchObject({ comparison: { ownership: 'foreign', status: 'foreign' }, state: 'conflicted' }); + expect(bare.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'AB7304', message: `Cursor plugin entry ${JSON.stringify(destination)} has no valid loader manifest.`, severity: 'error' }), + expect.objectContaining({ code: 'AB7321', severity: 'warning' }), + ])); + expect(await readFile(join(destination, 'state', 'plugin.sqlite'), 'utf8')).toBe('durable\n'); - // With the remnant receipt `uninstall --keep-data` writes, Doctor names the plugin and the receipt too. + // With the remnant receipt `uninstall --keep-data` writes, Doctor names the plugin and the receipt too; state/ is + // one more unowned entry the uninstall retained, never preserved runtime state. await removeTree(destination); await installBundle({ from: bundle, home: fixture.home, host: 'cursor' }); await mkdir(join(destination, 'state')); @@ -2627,7 +2569,6 @@ it('explains a Cursor directory holding only preserved runtime state instead of await uninstallBundle({ from: bundle, home: fixture.home, host: 'cursor' }); const remnant = hostReport(await doctor(), 'cursor'); expect(remnant.inventory.findings).toEqual([expect.objectContaining({ - legacyDurableState: expect.objectContaining({ summary: { bytes: 8, stores: 1 } }), name: 'doctor-fixture', path: destination, receipt: expect.objectContaining({ mode: 'local' }), @@ -2637,10 +2578,10 @@ it('explains a Cursor directory holding only preserved runtime state instead of expect(remnant.diagnostics.filter((entry) => entry.severity !== 'info')).toEqual([]); const remnantCodes = remnant.diagnostics.filter((entry) => entry.code === 'AB7307'); expect(remnantCodes.length).toBeGreaterThan(0); - expect(remnantCodes.every((entry) => entry.message.includes('holds only preserved runtime state'))).toBe(true); + expect(remnantCodes.every((entry) => entry.message.includes('retained the unowned entry "state"'))).toBe(true); + expect(remnantCodes.every((entry) => !entry.message.includes('preserved runtime state'))).toBe(true); - // A remnant receipt also guarding unowned entries the uninstall retained is not called state-only: Doctor - // names the retained entries and points at removing them by hand, since `uninstall` never will. Both the + // Doctor names every retained entry and points at removing them by hand, since `uninstall` never will. Both the // inventory finding and the exact-bundle (`--from`) finding check the directory contents, not just the receipt. await writeFile(join(destination, 'operator-notes.md'), 'mine\n'); const withExtras = hostReport(await doctor(), 'cursor'); @@ -2649,18 +2590,11 @@ it('explains a Cursor directory holding only preserved runtime state instead of const extras = withExtras.diagnostics.filter((entry) => entry.code === 'AB7307'); expect(extras.length).toBe(remnantCodes.length); for (const entry of extras) { - expect(entry.message).toContain('retained the unowned entry "operator-notes.md" beside preserved runtime state'); - expect(entry.message).not.toContain('holds only preserved runtime state'); + expect(entry.message).toContain('retained the unowned entries "operator-notes.md", "state"'); + expect(entry.message).not.toContain('preserved runtime state'); expect(entry.recovery).toContain('never removes unowned entries'); } - - // Without any state left, a remnant receipt over unowned entries is still not "state-only". await removeTree(join(destination, 'state')); - const noState = hostReport(await doctor(), 'cursor'); - for (const entry of noState.diagnostics.filter((item) => item.code === 'AB7307')) { - expect(entry.message).toContain('retained the unowned entry "operator-notes.md"'); - expect(entry.message).not.toContain('beside preserved runtime state'); - } // A remnant receipt recording a PLUGIN_DATA expansion names that directory as preserved state only while it is // real: this home's `agent-bundle/plugin-data/`, reached through real directories, existing and holding @@ -2694,12 +2628,6 @@ it('explains a Cursor directory holding only preserved runtime state instead of } await removeTree(pluginData); expect((await remnantMessages()).every((message) => !message.includes('PLUGIN_DATA') && !message.includes('state/'))).toBe(true); - // An emptied state/ directory left behind is not preserved state either. - await mkdir(join(destination, 'state')); - expect((await remnantMessages()).every((message) => message.includes('whose preserved runtime state has since been removed'))).toBe(true); - await writeFile(join(destination, 'state', 'plugin.sqlite'), 'durable\n'); - expect((await remnantMessages()).every((message) => message.includes('holds only preserved runtime state (state/)'))).toBe(true); - await removeTree(join(destination, 'state')); const elsewhere = join(fixture.root, 'other-home', '.cursor', 'agent-bundle', 'plugin-data', 'doctor-fixture'); await mkdir(elsewhere, { recursive: true }); await writeFile(join(elsewhere, 'cache.sqlite'), 'foreign\n'); @@ -2748,7 +2676,7 @@ const findDeadPid = (): Promise => new Promise((resolvePromise, reject) child.once('exit', () => { resolvePromise(pid); }); }); -it('reports old live runtime sockets as unsupported without warnings', async () => { +it('reports a live runtime socket that rejects the status request as a failed probe (AB7318)', async () => { const fixture = await temporaryDoctor(); const endpoint = join(fixture.endpointDirectory, 'event-live.sock'); const server = await listen(endpoint, { @@ -2766,12 +2694,17 @@ it('reports old live runtime sockets as unsupported without warnings', async () hosts: [], }); expect(report.endpoints.findings).toEqual(expect.arrayContaining([ - expect.objectContaining({ path: endpoint, runtime: { status: 'unsupported' }, state: 'live' }), + expect.objectContaining({ path: endpoint, runtime: { status: 'failed' }, state: 'live' }), expect.objectContaining({ path: `${endpoint}.lock`, state: 'live' }), ])); expect(report.diagnostics).toEqual(expect.arrayContaining([ - expect.objectContaining({ code: 'AB7317', severity: 'info' }), + expect.objectContaining({ + code: 'AB7318', + message: `Runtime socket ${JSON.stringify(endpoint)} status probe failed: Event runtime request does not match the wire schema.`, + severity: 'error', + }), ])); + expect(report.diagnostics.some((entry) => entry.code === 'AB7317')).toBe(false); expect(report.endpoints.summary).toMatchObject({ live: 1, staleLocks: 0, staleSockets: 0 }); } finally { await close(server); diff --git a/packages/agent-bundle/tests/event-ipc.test.ts b/packages/agent-bundle/tests/event-ipc.test.ts index 9223a8684..2b4d8977b 100644 --- a/packages/agent-bundle/tests/event-ipc.test.ts +++ b/packages/agent-bundle/tests/event-ipc.test.ts @@ -249,8 +249,8 @@ it.live('reports read-only runtime identity without an artifact epoch gate', () expect(byPath).toEqual(byId); })); -it.live('reports unsupported and unavailable status endpoints distinctly', () => Effect.gen(function*() { - const endpointId = `event-ipc-status-unsupported-${crypto.randomUUID()}`; +it.live('fails a status probe the server rejects and reports a missing endpoint as unavailable', () => Effect.gen(function*() { + const endpointId = `event-ipc-status-rejected-${crypto.randomUUID()}`; yield* Effect.scoped(Effect.gen(function*() { yield* Effect.acquireRelease( Effect.promise(() => createEventRuntimeServer({ @@ -260,10 +260,15 @@ it.live('reports unsupported and unavailable status endpoints distinctly', () => })), (runtime) => Effect.promise(() => runtime.close()), ); - expect(yield* Effect.promise(() => requestEventRuntimeStatus({ + const rejected = yield* Effect.promise(() => requestEventRuntimeStatus({ endpointId, timeoutMs: 1_000, - }))).toEqual({ status: 'unsupported' }); + }).then(() => undefined, (error: unknown) => error)); + expect(rejected).toBeInstanceOf(EventRuntimeTransportError); + expect(rejected).toMatchObject({ + code: 'runtime-failed', + message: 'Event runtime request does not match the wire schema.', + }); })); expect(yield* Effect.promise(() => requestEventRuntimeStatus({ diff --git a/packages/agent-bundle/tests/host-install-proof.test.ts b/packages/agent-bundle/tests/host-install-proof.test.ts index b577c3700..f9ddc1121 100644 --- a/packages/agent-bundle/tests/host-install-proof.test.ts +++ b/packages/agent-bundle/tests/host-install-proof.test.ts @@ -559,8 +559,8 @@ codexPluginIt( homeByteIdentical: false, host: 'codex', hostResidue: ['codex-empty-config', 'codex-empty-directories'], - // codex-cli 0.147.0 deletes the cached tree (state/ included) on `plugin remove` and has no keep-data option. - keepData: 'unavailable', + // codex-cli 0.147.0 deletes the cached tree on `plugin remove`; the receipt-recorded state root lives outside it. + keepData: 'kept', plan: 'no-op', proofLevel: proofLabel, purgeData: 'purged', @@ -586,7 +586,7 @@ it('uninstalls the Cursor local copy by its receipt and leaves the isolated home plan: 'no-op', proofLevel: proofLabel, purgeData: 'purged', - refusals: { foreignOrMismatch: 'AB7007', missingReceipt: 'AB7009', unconfirmedPurge: 'AB7008' }, + refusals: { foreignOrMismatch: 'AB7007', missingReceipt: 'AB7007', unconfirmedPurge: 'AB7008' }, registrations: { 'cursor-local-plugin': 'removed' }, rerun: 'not-installed', status: 'passed', diff --git a/packages/agent-bundle/tests/install-surface.test.ts b/packages/agent-bundle/tests/install-surface.test.ts index fd6468eea..bcd5d0a49 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -14,7 +14,6 @@ import type { NormalizedPlugin } from '../src/core/types.ts'; import { installReceiptFile, installReceiptFormat, - legacyInstallReceiptFormat, readInstallReceipt, readInstallReceiptFile, treeInventory, @@ -776,7 +775,8 @@ it('documents the same-version reinstall recipe per host, including Claude\'s ve const install = writesFor(target).get('INSTALL.md') ?? ''; expect(install).toContain(installReceiptFile); expect(install).toContain('--replace'); - expect(install).toContain('`state/`'); + expect(install).toContain('foreign'); + expect(install).not.toMatch(/adopt|pre-receipt legacy/u); expect(install).toContain('### Uninstall'); expect(install).toContain('node ./install.mjs --uninstall --plan'); expect(install).toContain('--purge-data --confirm-purge'); @@ -792,13 +792,12 @@ it('documents the same-version reinstall recipe per host, including Claude\'s ve expect(installer).toContain("argument === '--replace'"); expect(installer).toContain(`const receiptFile = ${JSON.stringify(installReceiptFile)};`); expect(installer).toContain(`const receiptFormat = ${JSON.stringify(installReceiptFormat)};`); - expect(installer).toContain(`const legacyReceiptFormat = ${JSON.stringify(legacyInstallReceiptFormat)};`); + expect(installer).not.toMatch(/legacy|migratedFrom|stateRoot\b|markerFiles|Adopted/u); expect(installer).toContain("if (uninstall && mode === 'local') {"); expect(installer).toContain("if (uninstall && mode === 'marketplace') {"); expect(installer).toContain('Refusing to uninstall foreign directory'); expect(installer).toContain('--purge-data deletes the plugin\'s durable runtime state'); expect(installer).toContain('Refusing foreign install'); - expect(installer).toContain('Refusing content collision'); expect(installer).toContain('Refusing version collision'); expect(installer).toContain('Refusing to overwrite unowned files'); expect(installer).toContain("!value.includes('\\\\')"); @@ -830,7 +829,7 @@ const listFiles = async (root: string): Promise => .map((entry) => join(entry.parentPath, entry.name).slice(root.length + 1)) .sort((left, right) => left.localeCompare(right)); -it('emitted install.mjs mirrors the core replace policy: no-op, owned-only replace, legacy gate, foreign refusal', async () => { +it('emitted install.mjs mirrors the core replace policy: no-op, owned-only replace, foreign refusal', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-install-mjs-')); const bundle = join(root, 'bundle'); const home = join(root, 'home'); @@ -937,21 +936,19 @@ it('emitted install.mjs mirrors the core replace policy: no-op, owned-only repla await rm(join(bundle, 'notes.md')); await rm(join(destination, 'notes.md')); - // Byte-identical legacy copy (receipt-less copies hash as a full tree, so runtime state is cleared - // first): plain rerun is a no-op, --replace adopts it by writing the receipt. + // A byte-identical copy without a receipt is foreign: refused with and without --replace, never adopted. + const receiptText = await readFile(join(destination, installReceiptFile), 'utf8'); await rm(join(destination, installReceiptFile)); await removeTree(join(destination, 'state')); - const identicalLegacy = await run(installer, [], home); - expect(identicalLegacy).toMatchObject({ code: 0, stderr: '' }); - expect(identicalLegacy.stdout).toContain('Already installed install-fixture@1.2.3'); + for (const args of [[], ['--replace']]) { + const preReceipt = await run(installer, args, home); + expect(preReceipt.code).toBe(1); + expect(preReceipt.stderr).toContain(`Refusing foreign install at ${destination}`); + expect(preReceipt.stderr).toContain('(same content)'); + expect(preReceipt.stderr).toContain('--replace does not apply'); + } expect(await readInstallReceipt(destination)).toBeUndefined(); - const adoptedIdentical = await run(installer, ['--replace'], home); - expect(adoptedIdentical).toMatchObject({ code: 0, stderr: '' }); - expect(adoptedIdentical.stdout).toContain('Adopted install-fixture@1.2.3'); - expect(await readInstallReceipt(destination)).toMatchObject({ - contentHash: (await treeInventory(bundle)).hash, - plugin: 'install-fixture', - }); + await writeFile(join(destination, installReceiptFile), receiptText); // Owned file -> directory restructure is a replacement, not a collision. await rm(join(bundle, 'payload.txt')); @@ -969,21 +966,20 @@ it('emitted install.mjs mirrors the core replace policy: no-op, owned-only repla expect(await readFile(join(destination, 'payload.txt'), 'utf8')).toBe('rebuilt\n'); // Only installer-created directories are pruned when a rebuild empties them; a pre-existing - // operator directory that a rebuild wrote beneath stays. (This copy was adopted from a legacy - // layout above, so `.cursor-plugin` predates the receipt and is not owned either.) + // operator directory that a rebuild wrote beneath stays. await mkdir(join(destination, 'operator-dir')); await mkdir(join(bundle, 'operator-dir')); await writeFile(join(bundle, 'operator-dir', 'shipped.md'), '# shipped\n'); await mkdir(join(bundle, 'skills', 'new'), { recursive: true }); await writeFile(join(bundle, 'skills', 'new', 'SKILL.md'), '# new\n'); expect((await run(installer, [], home)).stdout).toContain('Replaced install-fixture@1.2.3'); - expect((await readInstallReceipt(destination))?.directories).toEqual(['skills', 'skills/new']); + expect((await readInstallReceipt(destination))?.directories).toEqual(['.cursor-plugin', 'skills', 'skills/new']); await removeTree(join(bundle, 'operator-dir')); await removeTree(join(bundle, 'skills')); expect((await run(installer, [], home)).stdout).toContain('Replaced install-fixture@1.2.3'); expect(await readdir(join(destination, 'operator-dir'))).toEqual([]); await expect(readdir(join(destination, 'skills'))).rejects.toMatchObject({ code: 'ENOENT' }); - expect((await readInstallReceipt(destination))?.directories).toEqual([]); + expect((await readInstallReceipt(destination))?.directories).toEqual(['.cursor-plugin']); await removeTree(join(destination, 'operator-dir')); // A receipt whose inventory drifted is refreshed even when the owned bytes hash equal. @@ -995,15 +991,15 @@ it('emitted install.mjs mirrors the core replace policy: no-op, owned-only repla expect(refreshed.stdout).toContain('Replaced install-fixture@1.2.3'); expect((await readInstallReceipt(destination))?.files).not.toContain('transient.txt'); - // A receipt missing a field reads as absent, exactly like the core reader: the legacy gate applies. + // A receipt missing a field reads as absent, exactly like the core reader: the copy is foreign. const receipt = JSON.parse(await readFile(join(destination, installReceiptFile), 'utf8')) as Record; const { host: _host, ...partialReceipt } = receipt; await writeFile(join(destination, installReceiptFile), JSON.stringify(partialReceipt)); await writeFile(join(bundle, 'payload.txt'), 'rebuilt again\n'); const partial = await run(installer, [], home); expect(partial.code).toBe(1); - expect(partial.stderr).toContain('Refusing content collision'); - expect(partial.stderr).toContain('predates install receipts'); + expect(partial.stderr).toContain('Refusing foreign install'); + expect(partial.stderr).toContain('same version, different content'); // A receipt claiming runtime state reads as absent too: the durable store is never deletion-eligible. await mkdir(join(destination, 'state'), { recursive: true }); @@ -1012,18 +1008,17 @@ it('emitted install.mjs mirrors the core replace policy: no-op, owned-only repla ...receipt, files: [...(receipt['files'] as string[]), 'state/plugin.sqlite'], })); - const claimsState = await run(installer, [], home); - expect(claimsState.code).toBe(1); - expect(claimsState.stderr).toContain('predates install receipts'); - expect(await readFile(join(destination, 'state', 'plugin.sqlite'), 'utf8')).toBe('durable\n'); - const adoptedOverState = await run(installer, ['--replace'], home); - expect(adoptedOverState).toMatchObject({ code: 0, stderr: '' }); - expect(adoptedOverState.stdout).toContain('Replaced install-fixture@1.2.3'); + for (const args of [[], ['--replace']]) { + const claimsState = await run(installer, args, home); + expect(claimsState.code).toBe(1); + expect(claimsState.stderr).toContain('Refusing foreign install'); + } expect(await readFile(join(destination, 'state', 'plugin.sqlite'), 'utf8')).toBe('durable\n'); - expect((await readInstallReceipt(destination))?.files.some((file) => file.startsWith('state/'))).toBe(false); + expect(await readFile(join(destination, 'payload.txt'), 'utf8')).toBe('rebuilt\n'); await removeTree(join(destination, 'state')); + await writeFile(join(destination, installReceiptFile), JSON.stringify(receipt)); await writeFile(join(bundle, 'payload.txt'), 'rebuilt\n'); - await run(installer, [], home); + expect((await run(installer, [], home)).stdout).toContain('Already installed install-fixture@1.2.3'); // A receipt that is not a regular file (a FIFO would block the read forever) is refused before reading. if (process.platform !== 'win32') { @@ -1052,21 +1047,19 @@ it('emitted install.mjs mirrors the core replace policy: no-op, owned-only repla await rm(join(destination, 'skills')); await removeTree(join(bundle, 'skills')); - // Legacy pre-receipt copy with drift: refused with a hash comparison until --replace adopts it. + // A drifted copy without a receipt: foreign, refused with the hash comparison, never adopted. await rm(join(destination, installReceiptFile)); - await writeFile(join(destination, 'payload.txt'), 'legacy\n'); - const legacyHash = (await treeInventory(destination)).hash; - const legacy = await run(installer, [], home); - expect(legacy.code).toBe(1); - expect(legacy.stderr).toContain('Refusing content collision'); - expect(legacy.stderr).toContain(`content ${legacyHash.slice(0, 12)}`); - expect(legacy.stderr).toContain('same version, different content'); - expect(legacy.stderr).toContain('--replace'); - const adopted = await run(installer, ['--replace'], home); - expect(adopted).toMatchObject({ code: 0, stderr: '' }); - expect(adopted.stdout).toContain('Replaced install-fixture@1.2.3'); - expect(await readFile(join(destination, 'payload.txt'), 'utf8')).toBe('rebuilt\n'); - expect(await readInstallReceipt(destination)).toMatchObject({ plugin: 'install-fixture' }); + await writeFile(join(destination, 'payload.txt'), 'drifted\n'); + const driftedHash = (await treeInventory(destination)).hash; + for (const args of [[], ['--replace']]) { + const drifted = await run(installer, args, home); + expect(drifted.code).toBe(1); + expect(drifted.stderr).toContain('Refusing foreign install'); + expect(drifted.stderr).toContain(`content ${driftedHash.slice(0, 12)}`); + expect(drifted.stderr).toContain('same version, different content'); + } + expect(await readFile(join(destination, 'payload.txt'), 'utf8')).toBe('drifted\n'); + expect(await readInstallReceipt(destination)).toBeUndefined(); // Foreign directory under the plugin name: refused even with --replace. await removeTree(destination); @@ -1180,8 +1173,8 @@ it('emitted install.mjs reruns a marketplace stage with unlisted files as alread } }); -it('emitted install.mjs --uninstall --force removes present files from a pre-receipt copy and keeps state/', async () => { - const root = await mkdtemp(join(tmpdir(), 'agent-bundle-legacy-uninstall-')); +it('emitted install.mjs --uninstall refuses a pre-receipt copy as foreign even with --force', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-pre-receipt-uninstall-')); const bundle = join(root, 'bundle'); const home = join(root, 'home'); const destination = join(home, '.cursor', 'plugins', 'local', 'install-fixture'); @@ -1200,16 +1193,24 @@ it('emitted install.mjs --uninstall --force removes present files from a pre-rec files: [{ path: 'payload.txt' }], projections: [{ builtInHost: 'cursor', documents: { plugin: '.cursor-plugin/plugin.json' } }], })}\n`), - writeFile(join(destination, 'INSTALL.md'), 'legacy\n'), - writeFile(join(destination, 'install.mjs'), 'legacy\n'), + writeFile(join(destination, 'INSTALL.md'), 'older\n'), + writeFile(join(destination, 'install.mjs'), 'older\n'), writeFile(join(destination, '.cursor-plugin', 'plugin.json'), JSON.stringify({ name: 'install-fixture', version: '1.2.3' })), writeFile(join(destination, 'operator.txt'), 'operator\n'), writeFile(join(destination, 'state', 'plugin.sqlite'), 'durable\n'), ]); - const removed = await run(installer, ['--uninstall', '--force'], home); - expect(removed).toMatchObject({ code: 0, stderr: '' }); - await expect(readFile(join(destination, 'operator.txt'))).rejects.toMatchObject({ code: 'ENOENT' }); + const before = await listFiles(destination); + for (const args of [['--uninstall'], ['--uninstall', '--force']]) { + const refused = await run(installer, args, home); + expect(refused.code).toBe(1); + expect(refused.stderr).toContain( + `Refusing to uninstall foreign directory ${destination}: it carries no install receipt naming install-fixture (manifest names "install-fixture")`, + ); + expect(refused.stderr).toContain('--force does not apply to foreign directories'); + } + expect(await listFiles(destination)).toEqual(before); + expect(await readFile(join(destination, 'operator.txt'), 'utf8')).toBe('operator\n'); expect(await readFile(join(destination, 'state', 'plugin.sqlite'), 'utf8')).toBe('durable\n'); } finally { await removeTree(root); @@ -1286,8 +1287,8 @@ it('emitted install.mjs marks new explicit state roots and retains pre-existing } }, 60_000); -it('emitted install.mjs never derives legacy purge ownership from the current environment', async () => { - const root = await mkdtemp(join(tmpdir(), 'agent-bundle-legacy-state-mjs-')); +it('emitted install.mjs never derives purge ownership from the current environment for a receipt without a state block', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-stateless-receipt-mjs-')); const bundle = join(root, 'bundle'); const home = join(root, 'home'); const cursorRoot = join(home, '.cursor'); @@ -1315,20 +1316,8 @@ it('emitted install.mjs never derives legacy purge ownership from the current en await writeFile(join(originalStateRoot, 'state.sqlite'), 'original\n'); const receiptPath = join(destination, installReceiptFile); const receipt = JSON.parse(await readFile(receiptPath, 'utf8')) as Record; - const { - hostDirectories: _hostDirectories, - mode: _mode, - registrations: _registrations, - scope: _scope, - state: _state, - stateRoot: _stateRoot, - updatedAt: _updatedAt, - ...legacy - } = receipt; - await writeFile(receiptPath, JSON.stringify({ - ...legacy, - format: legacyInstallReceiptFormat, - })); + const { state: _state, ...withoutState } = receipt; + await writeFile(receiptPath, JSON.stringify(withoutState)); await mkdir(currentStateRoot, { recursive: true }); await writeFile(currentSentinel, 'unrelated\n'); @@ -1346,7 +1335,6 @@ it('emitted install.mjs never derives legacy purge ownership from the current en expect(kept.stdout).toContain(`Retained ${currentStateRoot} (unproven)`); const remnant = await readInstallReceipt(destination); expect(remnant?.state).toBeUndefined(); - expect(remnant?.stateRoot).toBeUndefined(); const purged = await run( installer, @@ -1359,31 +1347,6 @@ it('emitted install.mjs never derives legacy purge ownership from the current en expect(purged.stdout).toContain(`Retained ${currentStateRoot} (unproven)`); expect(await readFile(currentSentinel, 'utf8')).toBe('unrelated\n'); expect(await readFile(join(originalStateRoot, 'state.sqlite'), 'utf8')).toBe('original\n'); - - expect(await run(installer, [], home, originalEnvironment)).toMatchObject({ code: 0, stderr: '' }); - const currentReceipt = JSON.parse(await readFile(receiptPath, 'utf8')) as Record; - const { state: _currentState, ...recordedLegacy } = currentReceipt; - await writeFile(receiptPath, JSON.stringify({ - ...recordedLegacy, - stateRoot: { root: originalStateRoot, source: 'derived' }, - })); - const recordedPlan = await run( - installer, - ['--uninstall', '--purge-data', '--confirm-purge', '--plan'], - home, - currentEnvironment, - ); - expect(recordedPlan.stdout).toContain('Data (purge): purged'); - expect(recordedPlan.stdout).toContain(originalStateRoot); - expect(recordedPlan.stdout).not.toContain(currentStateRoot); - expect(await run( - installer, - ['--uninstall', '--purge-data', '--confirm-purge'], - home, - currentEnvironment, - )).toMatchObject({ code: 0, stderr: '' }); - await expect(readFile(join(originalStateRoot, 'state.sqlite'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); - expect(await readFile(currentSentinel, 'utf8')).toBe('unrelated\n'); } finally { await removeTree(root); } @@ -1449,7 +1412,8 @@ it('emitted install.mjs --uninstall mirrors the core lifecycle: plan, receipt-ow expect(diffTreeSnapshots(before, await snapshotTree(home))).toEqual({ added: [], changed: [], removed: [] }); expect((await run(installer, ['--uninstall'], home)).stdout).toContain('Not installed install-fixture@1.2.3'); - // Durable state and unowned files survive a default uninstall; state goes only with confirmed --purge-data. + // Unowned entries survive every uninstall, an in-tree state/ directory included: listed as retained, never + // purged (only receipt-recorded state roots are), and keeping the plugin root alive behind a remnant receipt. await run(installer, [], home); await mkdir(join(destination, 'state')); await writeFile(join(destination, 'state', 'plugin.sqlite'), 'durable\n'); @@ -1461,35 +1425,36 @@ it('emitted install.mjs --uninstall mirrors the core lifecycle: plan, receipt-ow expect(keepPlan.stdout).toContain(`Retained unowned under ${destination}:`); expect(keepPlan.stdout).toContain(' notes.md'); expect(keepPlan.stdout).toContain(' scratch/'); + expect(keepPlan.stdout).toContain(' state/plugin.sqlite'); const kept = await run(installer, ['--uninstall', '--keep-data'], home); expect(kept).toMatchObject({ code: 0, stderr: '' }); - expect(kept.stdout).toContain(`Data (keep): kept`); + expect(kept.stdout).toContain('Data (keep): absent'); expect(kept.stdout).toContain(`Retained unowned under ${destination}:`); expect(kept.stdout).toContain(' notes.md'); expect(kept.stdout).toContain(' scratch/'); + expect(kept.stdout).toContain(' state/plugin.sqlite'); expect(kept.stdout).toContain(`Remnant receipt: ${join(destination, installReceiptFile)}`); expect((await readdir(destination)).sort()).toEqual([installReceiptFile, 'notes.md', 'scratch', 'state']); // The remnant receipt owns nothing and remembers the host directories the install created. expect(await readInstallReceipt(destination)).toMatchObject({ files: [], hostDirectories: ['plugins', 'plugins/local'], registrations: [] }); await rm(join(destination, 'notes.md')); await removeTree(join(destination, 'scratch')); - // Reinstalling around the preserved state is an install, not a foreign-directory refusal. + // Reinstalling around the retained entries is an install, not a foreign-directory refusal. const reinstalled = await run(installer, [], home); expect(reinstalled).toMatchObject({ code: 0, stderr: '' }); expect(reinstalled.stdout).toContain('Installed install-fixture@1.2.3'); expect(await readFile(join(destination, 'state', 'plugin.sqlite'), 'utf8')).toBe('durable\n'); + // --purge-data has no authority over an in-tree state/: it is not a receipt-recorded root, so it stays. const purged = await run(installer, ['--uninstall', '--purge-data', '--confirm-purge'], home); expect(purged).toMatchObject({ code: 0, stderr: '' }); - expect(purged.stdout).toContain('Data (purge): purged'); - expect(purged.stdout).toContain(join(destination, 'state')); - expect(diffTreeSnapshots(before, await snapshotTree(home))).toEqual({ added: [], changed: [], removed: [] }); + expect(purged.stdout).toContain('Data (purge): absent'); + expect(purged.stdout).toContain(' state/plugin.sqlite'); + expect(purged.stdout).toContain(`Remnant receipt: ${join(destination, installReceiptFile)}`); + expect(await readFile(join(destination, 'state', 'plugin.sqlite'), 'utf8')).toBe('durable\n'); + expect((await readdir(destination)).sort()).toEqual([installReceiptFile, 'state']); - // A remnant whose state/ was removed by hand guards nothing: the keep-data no-op applies only while the preserved - // data is still there, so a default rerun consumes the remnant receipt and prunes the host directories it recorded. - await run(installer, [], home); - await mkdir(join(destination, 'state')); - await writeFile(join(destination, 'state', 'plugin.sqlite'), 'durable\n'); - await run(installer, ['--uninstall'], home); + // A remnant guarding retained entries is a keep-data no-op; once the operator removes state/ by hand the + // remnant guards nothing, and the rerun consumes it and prunes the host directories it recorded. expect((await run(installer, ['--uninstall'], home)).stdout).toContain('Not installed install-fixture@1.2.3'); await removeTree(join(destination, 'state')); const emptyRemnant = await run(installer, ['--uninstall'], home); @@ -1498,7 +1463,7 @@ it('emitted install.mjs --uninstall mirrors the core lifecycle: plan, receipt-ow expect(emptyRemnant.stdout).toContain('Data (keep): absent'); expect(emptyRemnant.stdout).not.toContain('Remnant receipt:'); expect(diffTreeSnapshots(before, await snapshotTree(home))).toEqual({ added: [], changed: [], removed: [] }); - // A state/ emptied by hand (directory left behind) is not durable state either: pruned with the exhausted remnant. + // A state/ emptied by hand is still an unowned directory: listed as retained and left in place. await run(installer, [], home); await mkdir(join(destination, 'state')); await writeFile(join(destination, 'state', 'plugin.sqlite'), 'durable\n'); @@ -1506,9 +1471,10 @@ it('emitted install.mjs --uninstall mirrors the core lifecycle: plan, receipt-ow await rm(join(destination, 'state', 'plugin.sqlite')); const emptiedState = await run(installer, ['--uninstall'], home); expect(emptiedState).toMatchObject({ code: 0, stderr: '' }); - expect(emptiedState.stdout).toContain('Uninstalled install-fixture@1.2.3'); - expect(emptiedState.stdout).toContain('state/ under the installed plugin root is empty and is pruned'); - expect(emptiedState.stdout).not.toContain('Remnant receipt:'); + expect(emptiedState.stdout).toContain('Not installed install-fixture@1.2.3'); + expect((await readdir(destination)).sort()).toEqual([installReceiptFile, 'state']); + await removeTree(join(destination, 'state')); + expect((await run(installer, ['--uninstall'], home)).stdout).toContain('Uninstalled install-fixture@1.2.3'); expect(diffTreeSnapshots(before, await snapshotTree(home))).toEqual({ added: [], changed: [], removed: [] }); // Modified owned content: refused with the hash comparison until --force. @@ -1524,28 +1490,28 @@ it('emitted install.mjs --uninstall mirrors the core lifecycle: plan, receipt-ow expect(forced.stdout).toContain('[--force]'); expect(diffTreeSnapshots(before, await snapshotTree(home))).toEqual({ added: [], changed: [], removed: [] }); - // Legacy (receipt-less) copy: AB7009-style refusal, then --force removes the inventoried files only. + // A receipt-less copy of this very artifact is foreign: refused with and without --force, nothing touched. await run(installer, [], home); await rm(join(destination, installReceiptFile)); - const legacy = await run(installer, ['--uninstall'], home); - expect(legacy.code).toBe(1); - expect(legacy.stderr).toContain('predates install receipts'); - const forcedLegacy = await run(installer, ['--uninstall', '--force'], home); - expect(forcedLegacy).toMatchObject({ code: 0, stderr: '' }); - expect(forcedLegacy.stdout).toContain('Receipt: forced-legacy'); - // The legacy inventory owns no host directories: plugins/local stays (it was not proven ours). - expect(await readdir(join(cursorRoot, 'plugins', 'local'))).toEqual([]); + const preReceiptFiles = await listFiles(destination); + for (const args of [['--uninstall'], ['--uninstall', '--force']]) { + const refused = await run(installer, args, home); + expect(refused.code).toBe(1); + expect(refused.stderr).toContain(`Refusing to uninstall foreign directory ${destination}: it carries no install receipt naming install-fixture (manifest names "install-fixture")`); + } + expect(await listFiles(destination)).toEqual(preReceiptFiles); await removeTree(join(cursorRoot, 'plugins')); - // A format/1 receipt is consumed as migrated. + // A format/1 receipt reads as no receipt: the copy is foreign and the file is left as it was. await run(installer, [], home); const written = JSON.parse(await readFile(join(destination, installReceiptFile), 'utf8')) as Record; - const { hostDirectories: _h, mode: _m, registrations: _r, scope: _s, updatedAt: _u, ...legacyReceipt } = written; - await writeFile(join(destination, installReceiptFile), JSON.stringify({ ...legacyReceipt, format: legacyInstallReceiptFormat })); - const migrated = await run(installer, ['--uninstall'], home); - expect(migrated).toMatchObject({ code: 0, stderr: '' }); - expect(migrated.stdout).toContain('Receipt: migrated'); - // The migrated receipt carried no host directories, so plugins/ stays behind; that is the honest downgrade. + const { hostDirectories: _h, mode: _m, registrations: _r, scope: _s, updatedAt: _u, ...formatOne } = written; + const formatOneText = JSON.stringify({ ...formatOne, format: 'agent-bundle-install-receipt/1' }); + await writeFile(join(destination, installReceiptFile), formatOneText); + const formatOneRefused = await run(installer, ['--uninstall', '--force'], home); + expect(formatOneRefused.code).toBe(1); + expect(formatOneRefused.stderr).toContain('Refusing to uninstall foreign directory'); + expect(await readFile(join(destination, installReceiptFile), 'utf8')).toBe(formatOneText); await removeTree(join(cursorRoot, 'plugins')); expect(diffTreeSnapshots(before, await snapshotTree(home))).toEqual({ added: [], changed: [], removed: [] }); diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index e93e394a6..98342d643 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -36,6 +36,7 @@ import { runCli } from '../src/cli.ts'; import { captureCliTerminal } from './support/cli-terminal.ts'; import { writeInstallFixtureManifest } from './support/install-fixture.ts'; import { removeTree } from './support/remove-tree.ts'; +import { diffTreeSnapshots, snapshotTree } from './support/tree-snapshot.ts'; interface CommandCall { readonly args: readonly string[]; @@ -1432,7 +1433,7 @@ it('replaces a stale same-version receipt-managed Cursor install in place, touch } }); -it('requires --replace for a legacy pre-receipt Cursor copy and then adopts it', async () => { +it('refuses a pre-receipt Cursor copy as foreign, byte-identical or not, with or without --replace', async () => { const fixture = await createHostBundle('cursor'); const home = await mkdtemp(join(tmpdir(), 'agent-bundle-home-')); await mkdir(join(home, '.cursor')); @@ -1442,48 +1443,38 @@ it('requires --replace for a legacy pre-receipt Cursor copy and then adopts it', await writeFile(join(fixture.bundleRoot, 'install.mjs'), '// installer\n'); await refreshCursorBundle(fixture); await cp(fixture.bundleRoot, destination, { recursive: true }); + const artifact = await treeInventory(fixture.bundleRoot); - // Byte-identical legacy copy: a plain rerun is a no-op; --replace adopts it by writing the receipt. - const identical = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); - expect(identical).toMatchObject({ state: 'already-installed' }); + const expectForeign = async (replace: boolean, installedHash: string, verdict: string): Promise => { + const refused = await installBundle({ from: fixture.from, home, host: 'cursor', replace, scope: 'user' }) + .catch((failure: unknown) => failure); + expect(refused).toBeInstanceOf(DiagnosticError); + const message = (refused as DiagnosticError).diagnostics[0]?.message ?? ''; + expect((refused as DiagnosticError).diagnostics[0]).toMatchObject({ code: 'AB7005', target: 'cursor' }); + expect(message).toContain(`Refusing foreign install at ${destination}`); + expect(message).toContain(`content ${installedHash.slice(0, 12)}`); + expect(message).toContain(`content ${artifact.hash.slice(0, 12)}`); + expect(message).toContain(verdict); + expect(message).toContain('--replace does not apply'); + }; + + // Byte-identical copy without a receipt: still foreign, never "already installed", never adopted. + await expectForeign(false, artifact.hash, 'same content'); + await expectForeign(true, artifact.hash, 'same content'); expect(await readInstallReceipt(destination)).toBeUndefined(); - const adopted = await installBundle({ from: fixture.from, home, host: 'cursor', replace: true, scope: 'user' }); - expect(adopted).toMatchObject({ contentHash: (await treeInventory(fixture.bundleRoot)).hash, state: 'adopted' }); - // Adoption created no directories, so the legacy copy's directories are never pruned. - expect(await readInstallReceipt(destination)).toMatchObject({ directories: [], plugin: 'install-fixture', version: '1.2.3' }); - await rm(join(destination, installReceiptFile)); await writeFile(join(destination, 'payload.txt'), 'stale\n'); - // Operator content and a file the rebuild dropped: a legacy copy has no inventory, so both stay. await writeFile(join(destination, 'operator-note.txt'), 'keep me\n'); - await writeFile(join(destination, 'dropped-by-rebuild.txt'), 'old artifact file\n'); - const legacyHash = (await treeInventory(destination)).hash; - const artifact = await treeInventory(fixture.bundleRoot); + const before = await snapshotTree(destination); + const driftedHash = (await treeInventory(destination)).hash; + await expectForeign(false, driftedHash, 'same version, different content'); + await expectForeign(true, driftedHash, 'same version, different content'); + expect(diffTreeSnapshots(before, await snapshotTree(destination))).toEqual({ added: [], changed: [], removed: [] }); - const refused = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }) - .catch((failure: unknown) => failure); - expect(refused).toBeInstanceOf(DiagnosticError); - const message = (refused as DiagnosticError).diagnostics[0]?.message ?? ''; - expect((refused as DiagnosticError).diagnostics[0]).toMatchObject({ code: 'AB7005', target: 'cursor' }); - expect(message).toContain('Refusing content collision'); - expect(message).toContain(`content ${legacyHash.slice(0, 12)}`); - expect(message).toContain(`content ${artifact.hash.slice(0, 12)}`); - expect(message).toContain('same version, different content'); - expect(message).toContain('--replace'); - - const replaced = await installBundle({ from: fixture.from, home, host: 'cursor', replace: true, scope: 'user' }); - expect(replaced).toMatchObject({ contentHash: artifact.hash, previousContentHash: legacyHash, state: 'replaced' }); - expect(await readFile(join(destination, 'payload.txt'), 'utf8')).toBe('payload\n'); - expect(await readFile(join(destination, 'operator-note.txt'), 'utf8')).toBe('keep me\n'); - expect(await readFile(join(destination, 'dropped-by-rebuild.txt'), 'utf8')).toBe('old artifact file\n'); - const receipt = await readInstallReceipt(destination); - expect(receipt).toMatchObject({ contentHash: artifact.hash, directories: [], plugin: 'install-fixture' }); - expect(receipt?.files).toEqual(artifact.files); - // From now on the leftovers are unowned: a later same-version replace leaves them alone. - await writeFile(join(fixture.bundleRoot, 'payload.txt'), 'rebuilt\n'); - await refreshCursorBundle(fixture); - await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); - expect(await readFile(join(destination, 'dropped-by-rebuild.txt'), 'utf8')).toBe('old artifact file\n'); + // Removing the copy by hand and reinstalling is the only path back to a receipt-managed install. + await removeTree(destination); + expect(await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' })).toMatchObject({ state: 'installed' }); + expect(await readInstallReceipt(destination)).toMatchObject({ contentHash: artifact.hash, plugin: 'install-fixture' }); } finally { await Promise.all([ removeTree(fixture.cleanupRoot), @@ -1845,23 +1836,8 @@ it('ignores receipts whose file list could escape the plugin root', async () => expect(await readInstallReceipt(root), missing).toBeUndefined(); } await writeJson(join(root, installReceiptFile), complete); - // A format/1 receipt (#420) reads with its lifecycle fields synthesized and the downgrade recorded (#101). - expect(await readInstallReceipt(root)).toEqual({ - contentHash: 'abc', - directories: ['skills', 'skills/probe'], - files: ['skills/probe/SKILL.md', 'plugin.json'], - format: installReceiptFormat, - host: 'cursor', - hostDirectories: [], - installedAt: '2026-09-03T00:00:00.000Z', - migratedFrom: 'agent-bundle-install-receipt/1', - mode: 'local', - plugin: 'install-fixture', - registrations: [{ kind: 'cursor-local-plugin' }], - scope: 'user', - updatedAt: '2026-09-03T00:00:00.000Z', - version: '1.2.3', - }); + // A format/1 receipt is not read: only the current format proves ownership. + expect(await readInstallReceipt(root)).toBeUndefined(); // A current-format receipt must carry every lifecycle field with a valid shape, or it reads as absent. const current = { ...complete, @@ -1934,13 +1910,12 @@ posixPermissionIt('tree inventory refuses paths that could not round-trip throug } }); -it('never lets a receipt claim runtime state: a receipt owning state/ reads as legacy and the store survives', async () => { +it('never lets a receipt claim runtime state: a receipt owning state/ reads as absent and the copy is foreign', async () => { const fixture = await createHostBundle('cursor'); const home = await mkdtemp(join(tmpdir(), 'agent-bundle-home-')); await mkdir(join(home, '.cursor')); const destination = join(home, '.cursor', 'plugins', 'local', 'install-fixture'); try { - // Emitted bundles carry the install surface; without a trusted receipt that is what marks a copy as legacy. await writeFile(join(fixture.bundleRoot, 'INSTALL.md'), '# install\n'); await writeFile(join(fixture.bundleRoot, 'install.mjs'), '// installer\n'); await refreshCursorBundle(fixture); @@ -1948,28 +1923,23 @@ it('never lets a receipt claim runtime state: a receipt owning state/ reads as l await mkdir(join(destination, 'state')); await writeFile(join(destination, 'state', 'plugin.sqlite'), 'durable\n'); - // A corrupted (or pre-policy) receipt that lists the durable store as an owned file. + // A corrupted receipt that lists the durable store as an owned file does not read at all. const receipt = JSON.parse(await readFile(join(destination, installReceiptFile), 'utf8')) as { files: string[] }; await writeJson(join(destination, installReceiptFile), { ...receipt, files: [...receipt.files, 'state/plugin.sqlite'] }); expect(await readInstallReceipt(destination)).toBeUndefined(); - // Same-version drift is no longer automatic: the copy is treated as legacy, nothing is touched. + // Without a readable receipt the copy is foreign: refused with and without --replace, nothing is touched. await writeFile(join(fixture.bundleRoot, 'payload.txt'), 'rebuilt\n'); await refreshCursorBundle(fixture); - const refused = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }) - .catch((failure: unknown) => failure); - expect(refused).toBeInstanceOf(DiagnosticError); - expect((refused as DiagnosticError).diagnostics[0]).toMatchObject({ code: 'AB7005', target: 'cursor' }); - expect((refused as DiagnosticError).diagnostics[0]?.message).toContain('predates install receipts'); + for (const replace of [false, true]) { + const refused = await installBundle({ from: fixture.from, home, host: 'cursor', replace, scope: 'user' }) + .catch((failure: unknown) => failure); + expect(refused).toBeInstanceOf(DiagnosticError); + expect((refused as DiagnosticError).diagnostics[0]).toMatchObject({ code: 'AB7005', target: 'cursor' }); + expect((refused as DiagnosticError).diagnostics[0]?.message).toContain(`Refusing foreign install at ${destination}`); + } expect(await readFile(join(destination, 'payload.txt'), 'utf8')).toBe('payload\n'); expect(await readFile(join(destination, 'state', 'plugin.sqlite'), 'utf8')).toBe('durable\n'); - - // Explicit adoption rewrites the artifact's files and leaves the store alone and unowned. - expect(await installBundle({ from: fixture.from, home, host: 'cursor', replace: true, scope: 'user' })) - .toMatchObject({ state: 'replaced' }); - expect(await readFile(join(destination, 'payload.txt'), 'utf8')).toBe('rebuilt\n'); - expect(await readFile(join(destination, 'state', 'plugin.sqlite'), 'utf8')).toBe('durable\n'); - expect((await readInstallReceipt(destination))?.files.some((file) => file.startsWith('state/'))).toBe(false); } finally { await Promise.all([ removeTree(fixture.cleanupRoot), diff --git a/packages/agent-bundle/tests/packed-host-install-proof.test.ts b/packages/agent-bundle/tests/packed-host-install-proof.test.ts index 2a45e1746..3342c60c0 100644 --- a/packages/agent-bundle/tests/packed-host-install-proof.test.ts +++ b/packages/agent-bundle/tests/packed-host-install-proof.test.ts @@ -401,7 +401,7 @@ it('uninstalls the packed tarball from an isolated Cursor home and leaves it byt plan: 'no-op', proofLevel: proofLabel, purgeData: 'purged', - refusals: { foreignOrMismatch: 'AB7007', missingReceipt: 'AB7009', unconfirmedPurge: 'AB7008' }, + refusals: { foreignOrMismatch: 'AB7007', missingReceipt: 'AB7007', unconfirmedPurge: 'AB7008' }, registrations: { 'cursor-local-plugin': 'removed' }, rerun: 'not-installed', status: 'passed', diff --git a/packages/agent-bundle/tests/support/host-install.ts b/packages/agent-bundle/tests/support/host-install.ts index c8c205d11..b79a656bc 100644 --- a/packages/agent-bundle/tests/support/host-install.ts +++ b/packages/agent-bundle/tests/support/host-install.ts @@ -5,6 +5,7 @@ import { delimiter, dirname, isAbsolute, join, relative, resolve, sep } from 'no import { promisify } from 'node:util'; import { createHash, randomUUID } from 'node:crypto'; +import { userDataStateRoot } from '@agent-bundle/runtime'; import { Client } from '@modelcontextprotocol/client'; import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; import { parse as parseYaml } from 'yaml'; @@ -2709,13 +2710,14 @@ export interface HostUninstallProofReport { readonly host: DevInstallHost; /** Classified host-owned residue after uninstall, relative to the host root; empty when byte-identical. */ readonly hostResidue: readonly HostResidueClass[]; - readonly keepData: 'kept' | 'retained-by-host' | 'unavailable'; + readonly keepData: 'kept' | 'retained-by-host'; readonly plan: 'no-op'; readonly proofLevel: string; readonly purgeData: 'purged' | 'removed-by-host'; readonly refusals: { readonly foreignOrMismatch: 'AB7007'; - readonly missingReceipt: 'AB7009'; + /** A receipt-less Cursor copy is foreign (AB7007); a host-CLI install without a store receipt is AB7009. */ + readonly missingReceipt: 'AB7007' | 'AB7009'; readonly unconfirmedPurge: 'AB7008'; }; readonly registrations: Readonly>; @@ -2792,6 +2794,7 @@ export const runHostUninstallProof = async ( ...(host === 'claude' ? { CLAUDE_CONFIG_DIR: config } : {}), ...(host === 'codex' ? { CODEX_HOME: config } : {}), HOME: home, + XDG_STATE_HOME: join(root, 'state-home'), }); const hostRoot = host === 'cursor' ? home : config; const bundle = fixture.bundles[host]; @@ -2812,7 +2815,8 @@ export const runHostUninstallProof = async ( const receiptPath = host === 'cursor' ? join(installedRoot, '.agent-bundle-install.json') : join(config, 'agent-bundle', 'receipts', `${plugin}.${marketplace}.user.json`); - const stateDirectory = join(installedRoot, 'state'); + // The framework state root the receipt records for the installed copy: the only durable state a purge may remove. + const stateDirectory = userDataStateRoot(installedRoot, environment, home); try { const untouched = await snapshotTree(hostRoot); const homeBefore = await snapshotTree(home); @@ -2915,52 +2919,50 @@ export const runHostUninstallProof = async ( const homeByteIdentical = treesIdentical(before, afterUninstall); assertProof(host !== 'cursor' || homeByteIdentical, `Cursor uninstall did not leave the home byte-identical: ${JSON.stringify(difference)}`); - // Data policy: --keep-data preserves durable state (or says honestly why it cannot), a confirmed purge removes it. + // Data policy: --keep-data preserves the receipt-recorded state root, a confirmed purge removes it. await install(); await mkdir(stateDirectory, { recursive: true }); await writeFile(join(stateDirectory, 'plugin.sqlite'), 'durable\n'); const kept = await uninstall(['--keep-data']); const keepOutcome = kept.data?.outcome; assertProof( - keepOutcome === 'kept' || keepOutcome === 'retained-by-host' || keepOutcome === 'unavailable', + keepOutcome === 'kept' || keepOutcome === 'retained-by-host', `${host} --keep-data reported an unexpected outcome: ${JSON.stringify(kept.data)}`, ); - if (keepOutcome !== 'unavailable') { - await access(join(stateDirectory, 'plugin.sqlite')).catch(() => fail(`${host} --keep-data did not preserve state/plugin.sqlite.`)); - } + await access(join(stateDirectory, 'plugin.sqlite')).catch(() => fail(`${host} --keep-data did not preserve the recorded state root.`)); if (host === 'cursor') { assertProof(kept.remnantReceipt === receiptPath, 'Cursor --keep-data wrote no remnant receipt beside the preserved state.'); } await install(); - if (host !== 'cursor') { - // Codex deletes the cache on remove and Claude's reinstall rewrites the cached copy from the bundle, so - // the host itself discarded the marker; recreate it so the purge run has state to report on. - await mkdir(stateDirectory, { recursive: true }); - await writeFile(join(stateDirectory, 'plugin.sqlite'), 'durable\n'); - } const purged = await uninstall(['--purge-data', '--confirm-purge']); const purgeOutcome = purged.data?.outcome; assertProof(purgeOutcome === 'purged' || purgeOutcome === 'removed-by-host', `${host} --purge-data reported an unexpected outcome: ${JSON.stringify(purged.data)}`); await access(stateDirectory).then( - () => fail(`${host} --purge-data --confirm-purge left state/ behind.`), + () => fail(`${host} --purge-data --confirm-purge left the recorded state root behind.`), () => undefined, ); if (host === 'cursor') { assertProof(treesIdentical(before, await snapshotTree(hostRoot)), 'Cursor keep→reinstall→purge cycle did not restore the home byte-identically.'); } - // Refusals: a missing receipt (AB7009) and a mismatch between the installed copy and the receipt (AB7007). + // Refusals: a missing receipt and a mismatch between the installed copy and the receipt (AB7007). await install(); await rm(receiptPath); - await expectRefusal(lifecycle('uninstall'), 'AB7009', `${host} uninstall without a receipt`); - // A receipt-less Cursor copy is the pre-receipt legacy layout (`forced-legacy`); a host-CLI install with no - // store receipt is `forced-missing`. Both need --force and both remove only what the receipt (or, for the - // legacy layout, the inventory) proves is this plugin's. - const forced = await uninstall(['--force']); - assertProof( - forced.state === 'uninstalled' && (forced.receipt?.status === 'forced-missing' || forced.receipt?.status === 'forced-legacy'), - `${host} uninstall --force did not proceed: ${JSON.stringify(forced)}`, - ); + const missingReceipt = host === 'cursor' ? 'AB7007' : 'AB7009'; + await expectRefusal(lifecycle('uninstall'), missingReceipt, `${host} uninstall without a receipt`); + if (host === 'cursor') { + // A receipt-less Cursor copy is foreign: nothing proves it is this plugin's, so --force does not apply. + await expectRefusal(lifecycle('uninstall', ['--force']), 'AB7007', 'cursor uninstall --force without a receipt'); + await access(join(installedRoot, 'INSTALL.md')).catch(() => fail('Cursor refused a foreign uninstall but removed files anyway.')); + await removeTree(installedRoot); + } else { + // 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( + forced.state === 'uninstalled' && forced.receipt?.status === 'forced-missing', + `${host} uninstall --force did not proceed: ${JSON.stringify(forced)}`, + ); + } await install(); await writeFile(join(installedRoot, 'INSTALL.md'), '# tampered\n'); await expectRefusal(lifecycle('uninstall'), 'AB7007', `${host} uninstall of a modified copy`); @@ -2976,7 +2978,7 @@ export const runHostUninstallProof = async ( plan: 'no-op', proofLevel, purgeData: purgeOutcome, - refusals: Object.freeze({ foreignOrMismatch: 'AB7007', missingReceipt: 'AB7009', unconfirmedPurge: 'AB7008' }), + refusals: Object.freeze({ foreignOrMismatch: 'AB7007', missingReceipt, unconfirmedPurge: 'AB7008' }), registrations: Object.freeze(registrations), rerun: 'not-installed', status: 'passed', @@ -3007,10 +3009,11 @@ export const runPortableUninstallProof = async ( const home = await mkdtemp(join(tmpdir(), 'agent-bundle-host-uninstall-portable-')); try { await mkdir(join(home, '.cursor'), { recursive: true }); - const environment = isolatedEnvironment(options.environment, { HOME: home }); + const environment = isolatedEnvironment(options.environment, { HOME: home, XDG_STATE_HOME: join(home, 'state-home') }); const installer = join(fixture.portableBundle, 'install.mjs'); const destination = join(home, '.cursor', 'plugins', 'local', portablePlugin); const receiptPath = join(destination, '.agent-bundle-install.json'); + const stateRoot = userDataStateRoot(destination, environment, home); const runInstaller = (args: readonly string[]): Promise => run(process.execPath, [installer, ...args], { cwd: fixture.portableBundle, environment }); const expectOk = async (args: readonly string[], prefix: string): Promise => { @@ -3033,21 +3036,26 @@ export const runPortableUninstallProof = async ( await expectOk(['--uninstall'], `Not installed ${portablePlugin}@${version}`); await expectOk([], `Installed ${portablePlugin}@${version}`); - await mkdir(join(destination, 'state')); - await writeFile(join(destination, 'state', 'plugin.sqlite'), 'durable\n'); + await mkdir(stateRoot, { recursive: true }); + await writeFile(join(stateRoot, 'plugin.sqlite'), 'durable\n'); const kept = await expectOk(['--uninstall', '--keep-data'], `Uninstalled ${portablePlugin}@${version}`); assertProof(kept.includes('Data (keep): kept'), 'Portable --keep-data did not report kept state.'); - await access(join(destination, 'state', 'plugin.sqlite')).catch(() => fail('Portable --keep-data did not preserve state/.')); + await access(join(stateRoot, 'plugin.sqlite')).catch(() => fail('Portable --keep-data did not preserve the recorded state root.')); await expectOk([], `Installed ${portablePlugin}@${version}`); const purged = await expectOk(['--uninstall', '--purge-data', '--confirm-purge'], `Uninstalled ${portablePlugin}@${version}`); assertProof(purged.includes('Data (purge): purged'), 'Portable confirmed purge did not report purged state.'); + await access(stateRoot).then(() => fail('Portable confirmed purge left the recorded state root behind.'), () => undefined); + await removeTree(join(home, 'state-home')); assertProof(treesIdentical(before, await snapshotTree(home)), 'Portable keep→reinstall→purge did not restore the home byte-identically.'); + // A receipt-less copy is foreign: refused with and without --force, and nothing under it is touched. await expectOk([], `Installed ${portablePlugin}@${version}`); await rm(receiptPath); - const missing = await runInstaller(['--uninstall']); - assertProof(missing.exitCode === 1 && missing.stderr.includes('predates install receipts'), 'Portable uninstall without a receipt was not refused.'); - await expectOk(['--uninstall', '--force'], `Uninstalled ${portablePlugin}@${version}`); + for (const args of [['--uninstall'], ['--uninstall', '--force']]) { + const missing = await runInstaller(args); + assertProof(missing.exitCode === 1 && missing.stderr.includes('Refusing to uninstall foreign directory'), `Portable ${args.join(' ')} without a receipt was not refused.`); + } + await access(join(destination, 'install.mjs')).catch(() => fail('Portable refused uninstall still removed files.')); await removeTree(join(home, '.cursor', 'plugins')); await mkdir(destination, { recursive: true }); await writeFile(join(destination, 'payload.txt'), 'someone else\n'); diff --git a/packages/agent-bundle/tests/uninstall.test.ts b/packages/agent-bundle/tests/uninstall.test.ts index 5694fe83b..2b1864e63 100644 --- a/packages/agent-bundle/tests/uninstall.test.ts +++ b/packages/agent-bundle/tests/uninstall.test.ts @@ -224,27 +224,27 @@ it('keeps Cursor runtime state and unowned entries by default and purges state o expect(conflicting.diagnostics[0]?.code).toBe('AB7008'); expect(await readFile(join(destination, 'state', 'plugin.sqlite'), 'utf8')).toBe('durable\n'); - // --plan lists only the directories the run can actually prune: the plugin root is kept alive by state/ and - // the unowned note, so it is not planned for removal and the remnant receipt is announced instead. + // --plan lists only the directories the run can actually prune: the plugin root is kept alive by the unowned + // entries (state/ is one of them), so it is not planned for removal and the remnant receipt is announced instead. const keepPlan = await uninstallBundle({ ...options, keepData: true, plan: true }); expect(keepPlan.removed.directories).not.toContain(destination); expect(keepPlan.removed.directories).toContain(join(destination, '.cursor-plugin')); // skills/ is owned but skills/drafts is not: the unowned directory survives and keeps skills/ alive. expect(keepPlan.removed.directories).not.toContain(join(destination, 'skills')); expect(keepPlan.remnantReceipt).toBe(join(destination, installReceiptFile)); - expect(keepPlan.retained).toEqual(['operator-notes.md', 'scratch/', 'skills/drafts/']); - // Purging state/ still leaves the note, so the root survives that plan too; the purged directory is listed as one. + expect(keepPlan.retained).toEqual(['operator-notes.md', 'scratch/', 'skills/drafts/', 'state/plugin.sqlite']); + // Only the receipt-recorded derived root is purge authority; the in-tree state/ is never a data path. const purgePlan = await uninstallBundle({ ...options, confirmPurge: true, plan: true, purgeData: true }); - expect(purgePlan.data.paths).toEqual([derivedStateRoot, join(destination, 'state')]); - expect(purgePlan.removed.directories.slice(0, 2)).toEqual([derivedStateRoot, join(destination, 'state')]); + expect(purgePlan.data.paths).toEqual([derivedStateRoot]); + expect(purgePlan.removed.directories[0]).toBe(derivedStateRoot); expect(purgePlan.removed.directories).not.toContain(destination); - expect(purgePlan.removed.files).not.toContain(join(destination, 'state')); + expect(purgePlan.removed.directories).not.toContain(join(destination, 'state')); const kept = await uninstallBundle({ ...options, keepData: true }); expect(kept).toMatchObject({ - data: { outcome: 'kept', paths: [derivedStateRoot, join(destination, 'state')], policy: 'keep' }, + data: { outcome: 'kept', paths: [derivedStateRoot], policy: 'keep' }, remnantReceipt: join(destination, installReceiptFile), - retained: ['operator-notes.md', 'scratch/', 'skills/drafts/'], + retained: ['operator-notes.md', 'scratch/', 'skills/drafts/', 'state/plugin.sqlite'], state: 'uninstalled', }); expect(kept.removed.directories).toEqual(keepPlan.removed.directories); @@ -255,10 +255,11 @@ it('keeps Cursor runtime state and unowned entries by default and purges state o expect(await readInstallReceipt(destination)).toMatchObject({ files: [], hostDirectories: [], mode: 'local', registrations: [] }); expect(await readFile(join(destination, 'state', 'plugin.sqlite'), 'utf8')).toBe('durable\n'); expect(await readFile(join(derivedStateRoot, 'plugin.sqlite'), 'utf8')).toBe('derived\n'); - expect(formatUninstallResult(kept)).toContain('Retained 3 unowned entries'); + expect(formatUninstallResult(kept)).toContain('Retained 4 unowned entries'); expect(formatUninstallResult(kept)).toContain('Remnant receipt:'); - // Reinstall beside the retained state (an install, not a replacement), then purge it with confirmation. + // Reinstall beside the retained entries (an install, not a replacement), then purge the derived root with + // confirmation; state/ stays retained and keeps the root and its remnant receipt alive. await rm(join(destination, 'operator-notes.md')); await removeTree(join(destination, 'scratch')); // skills/ survived the uninstall (its unowned child kept it alive), so a reinstall would find it pre-existing @@ -268,15 +269,18 @@ it('keeps Cursor runtime state and unowned entries by default and purges state o expect(await readFile(join(destination, 'state', 'plugin.sqlite'), 'utf8')).toBe('durable\n'); const purged = await uninstallBundle({ ...options, confirmPurge: true, purgeData: true }); expect(purged).toMatchObject({ - data: { outcome: 'purged', paths: [derivedStateRoot, join(destination, 'state')], policy: 'purge' }, - retained: [], + data: { outcome: 'purged', paths: [derivedStateRoot], policy: 'purge' }, + remnantReceipt: join(destination, installReceiptFile), + retained: ['state/plugin.sqlite'], state: 'uninstalled', }); - expect(purged.remnantReceipt).toBeUndefined(); - // A purged state/ tree is a directory and is reported as one, ahead of the pruned owned directories. - expect(purged.removed.directories.slice(0, 2)).toEqual([derivedStateRoot, join(destination, 'state')]); - expect(purged.removed.files).not.toContain(join(destination, 'state')); + // The purged derived root is a directory and is reported as one, ahead of the pruned owned directories. + expect(purged.removed.directories[0]).toBe(derivedStateRoot); await expect(readdir(derivedStateRoot)).rejects.toMatchObject({ code: 'ENOENT' }); + expect(await readFile(join(destination, 'state', 'plugin.sqlite'), 'utf8')).toBe('durable\n'); + // Once the operator removes state/ by hand, the exhausted remnant is consumed and the home is byte-identical. + await removeTree(join(destination, 'state')); + expect(await uninstallBundle(options)).toMatchObject({ data: { outcome: 'absent' }, state: 'uninstalled' }); expect(diffTreeSnapshots(before, await snapshotTree(fixture.home))).toEqual({ added: [], changed: [], removed: [] }); } finally { await removeTree(fixture.cleanupRoot); @@ -294,27 +298,31 @@ it('keeps created host directories receipt-owned across a --keep-data cycle in a await installBundle(options); await mkdir(join(destination, 'state')); await writeFile(join(destination, 'state', 'plugin.sqlite'), 'durable\n'); - // Keep: plugins/ and plugins/local cannot be pruned (they hold the state), so the remnant receipt remembers them. + // Keep: plugins/ and plugins/local cannot be pruned (the unowned state/ keeps the root alive), so the remnant + // receipt remembers them. const kept = await uninstallBundle(options); - expect(kept.remnantReceipt).toBe(join(destination, installReceiptFile)); + expect(kept).toMatchObject({ data: { outcome: 'absent' }, remnantReceipt: join(destination, installReceiptFile), retained: ['state/plugin.sqlite'] }); expect(await readInstallReceipt(destination)).toMatchObject({ hostDirectories: ['plugins', 'plugins/local'], registrations: [] }); - // Uninstalling the remnant itself while still keeping the data is the documented no-op: nothing to remove, the - // remnant receipt stays in place unchanged, and the run reports `not-installed`. + // Uninstalling the remnant itself while the retained entry is still there is the documented no-op: nothing to + // remove, the remnant receipt stays in place unchanged, and the run reports `not-installed`. const remnantBefore = await readFile(join(destination, installReceiptFile), 'utf8'); const rerun = await uninstallBundle(options); expect(rerun).toMatchObject({ - data: { outcome: 'kept' }, + data: { outcome: 'absent' }, receipt: { status: 'remnant' }, registrations: [{ action: 'already-absent', kind: 'cursor-local-plugin' }], remnantReceipt: join(destination, installReceiptFile), removed: { directories: [], files: [] }, + retained: ['state/plugin.sqlite'], state: 'not-installed', }); expect(await readFile(join(destination, installReceiptFile), 'utf8')).toBe(remnantBefore); expect(await uninstallBundle({ ...options, plan: true })).toMatchObject({ receipt: { status: 'remnant' }, state: 'not-installed' }); - // Reinstall around the state carries the host directories forward; a confirmed purge then restores the home exactly. + // Reinstall around the retained entry carries the host directories forward; once the operator removes state/, + // a confirmed purge restores the home exactly. expect(await installBundle(options)).toMatchObject({ state: 'installed' }); expect(await readInstallReceipt(destination)).toMatchObject({ hostDirectories: ['plugins', 'plugins/local'], registrations: [{ kind: 'cursor-local-plugin' }] }); + await removeTree(join(destination, 'state')); const purged = await uninstallBundle({ ...options, confirmPurge: true, purgeData: true }); expect(purged.removed.directories).toEqual(expect.arrayContaining([join(cursorRoot, 'plugins', 'local'), join(cursorRoot, 'plugins')])); expect(diffTreeSnapshots(before, await snapshotTree(fixture.home))).toEqual({ added: [], changed: [], removed: [] }); @@ -341,20 +349,23 @@ it('keeps created host directories receipt-owned across a --keep-data cycle in a expect(consumed.remnantReceipt).toBeUndefined(); expect(diffTreeSnapshots(before, await snapshotTree(fixture.home))).toEqual({ added: [], changed: [], removed: [] }); - // A state/ directory emptied by hand (the directory itself left behind) is not durable state either: the remnant - // is exhausted, the empty state/ is pruned with the root, and the home is byte-identical again. + // A state/ directory emptied by hand is still an unowned directory: the uninstall never prunes it, so the remnant + // stays a no-op until the operator removes the directory itself. await installBundle(options); await mkdir(join(destination, 'state')); await writeFile(join(destination, 'state', 'plugin.sqlite'), 'durable\n'); expect((await uninstallBundle(options)).remnantReceipt).toBe(join(destination, installReceiptFile)); await rm(join(destination, 'state', 'plugin.sqlite')); - const emptiedState = await uninstallBundle(options); - expect(emptiedState).toMatchObject({ - data: { detail: expect.stringContaining('state/ under the installed plugin root is empty and is pruned'), outcome: 'absent' }, - receipt: { status: 'consumed' }, - state: 'uninstalled', + expect(await uninstallBundle(options)).toMatchObject({ + receipt: { status: 'remnant' }, + removed: { directories: [], files: [] }, + retained: ['state/'], + state: 'not-installed', }); - expect(emptiedState.removed.directories).toEqual([join(destination, 'state'), destination, join(cursorRoot, 'plugins', 'local'), join(cursorRoot, 'plugins')]); + await removeTree(join(destination, 'state')); + const emptiedState = await uninstallBundle(options); + expect(emptiedState).toMatchObject({ data: { outcome: 'absent' }, receipt: { status: 'consumed' }, state: 'uninstalled' }); + expect(emptiedState.removed.directories).toEqual([destination, join(cursorRoot, 'plugins', 'local'), join(cursorRoot, 'plugins')]); expect(emptiedState.remnantReceipt).toBeUndefined(); expect(diffTreeSnapshots(before, await snapshotTree(fixture.home))).toEqual({ added: [], changed: [], removed: [] }); @@ -872,21 +883,22 @@ it('refuses Cursor local uninstalls without proof of ownership unless forced, an expect(await uninstallBundle({ ...options, force: true })).toMatchObject({ receipt: { status: 'forced-mismatch' }, state: 'uninstalled' }); await expect(readdir(destination)).rejects.toMatchObject({ code: 'ENOENT' }); - // Legacy pre-receipt layout: refused (AB7009) until --force, which removes the inventoried files only. + // A copy without a receipt (placed before receipts existed) is foreign: refused with and without --force, + // and nothing under it is touched. await installBundle(options); await rm(receiptPath); await mkdir(join(destination, 'state')); await writeFile(join(destination, 'state', 'plugin.sqlite'), 'durable\n'); - const legacy = await failureOf(uninstallBundle(options)); - expect(legacy.diagnostics[0]).toMatchObject({ code: 'AB7009', target: 'cursor' }); - expect(legacy.diagnostics[0]?.message).toContain('predates install receipts'); - const forcedLegacy = await uninstallBundle({ ...options, force: true }); - expect(forcedLegacy).toMatchObject({ - data: { outcome: 'kept' }, - receipt: { status: 'forced-legacy' }, - state: 'uninstalled', - }); - expect((await readdir(destination)).sort()).toEqual([installReceiptFile, 'state']); + const preReceipt = await snapshotTree(destination); + for (const force of [false, true]) { + const refused = await failureOf(uninstallBundle({ ...options, force })); + expect(refused.diagnostics[0]).toMatchObject({ code: 'AB7007', target: 'cursor' }); + expect(refused.diagnostics[0]?.message).toContain( + `Refusing to uninstall foreign directory ${destination}: it carries no install receipt naming uninstall-fixture (manifest names "uninstall-fixture")`, + ); + expect(refused.diagnostics[0]?.message).toContain('--force does not apply to foreign directories'); + } + expect(diffTreeSnapshots(preReceipt, await snapshotTree(destination))).toEqual({ added: [], changed: [], removed: [] }); await removeTree(destination); // A receipt naming another plugin, or a directory that is not ours at all: refused even with --force. @@ -918,40 +930,36 @@ it('refuses Cursor local uninstalls without proof of ownership unless forced, an } }); -it('consumes a migrated format/1 Cursor receipt without a crash', async () => { +it('treats a format/1 Cursor receipt as no receipt: install and uninstall refuse the copy as foreign', async () => { const fixture = await createFixture('cursor'); const cursorRoot = join(fixture.home, '.cursor'); await mkdir(join(cursorRoot, 'plugins', 'local'), { recursive: true }); const destination = join(cursorRoot, 'plugins', 'local', 'uninstall-fixture'); const options = { from: fixture.bundleRoot, home: fixture.home, host: 'cursor' as const }; try { - const before = await snapshotTree(fixture.home); await installBundle(options); const receiptPath = join(destination, installReceiptFile); const receipt = JSON.parse(await readFile(receiptPath, 'utf8')) as Record; - const { hostDirectories: _h, mode: _m, registrations: _r, scope: _s, updatedAt: _u, ...legacy } = receipt; - await writeFile(receiptPath, JSON.stringify({ ...legacy, format: 'agent-bundle-install-receipt/1' })); - expect((await readInstallReceipt(destination))?.migratedFrom).toBe('agent-bundle-install-receipt/1'); - - // An identical rerun of install upgrades the receipt in place without touching plugin files. - const upgraded = await installBundle(options); - expect(upgraded.state).toBe('already-installed'); - expect(await readInstallReceipt(destination)).toMatchObject({ format: installReceiptFormat, mode: 'local' }); - expect((await readInstallReceipt(destination))?.migratedFrom).toBeUndefined(); + const { hostDirectories: _h, mode: _m, registrations: _r, scope: _s, updatedAt: _u, ...formatOne } = receipt; + await writeFile(receiptPath, JSON.stringify({ ...formatOne, format: 'agent-bundle-install-receipt/1' })); + expect(await readInstallReceipt(destination)).toBeUndefined(); + const before = await snapshotTree(fixture.home); - await writeFile(receiptPath, JSON.stringify({ ...legacy, format: 'agent-bundle-install-receipt/1' })); - const result = await uninstallBundle(options); - expect(result).toMatchObject({ - receipt: { migratedFrom: 'agent-bundle-install-receipt/1', status: 'migrated' }, - state: 'uninstalled', - }); + const install = await failureOf(installBundle({ ...options, replace: true })); + expect(install.diagnostics[0]).toMatchObject({ code: 'AB7005', target: 'cursor' }); + expect(install.diagnostics[0]?.message).toContain(`Refusing foreign install at ${destination}`); + for (const force of [false, true]) { + const uninstall = await failureOf(uninstallBundle({ ...options, force })); + expect(uninstall.diagnostics[0]).toMatchObject({ code: 'AB7007', target: 'cursor' }); + expect(uninstall.diagnostics[0]?.message).toContain(`Refusing to uninstall foreign directory ${destination}`); + } expect(diffTreeSnapshots(before, await snapshotTree(fixture.home))).toEqual({ added: [], changed: [], removed: [] }); } finally { await removeTree(fixture.cleanupRoot); } }); -it('never derives legacy receipt purge ownership from the current environment', async () => { +it('never derives purge ownership from the current environment for a receipt without a state block', async () => { const fixture = await createFixture('cursor'); const cursorRoot = join(fixture.home, '.cursor'); await mkdir(join(cursorRoot, 'plugins', 'local'), { recursive: true }); @@ -968,20 +976,8 @@ it('never derives legacy receipt purge ownership from the current environment', await writeFile(join(originalStateRoot, 'state.sqlite'), 'original\n'); const receiptPath = join(destination, installReceiptFile); const receipt = JSON.parse(await readFile(receiptPath, 'utf8')) as Record; - const { - hostDirectories: _hostDirectories, - mode: _mode, - registrations: _registrations, - scope: _scope, - state: _state, - stateRoot: _stateRoot, - updatedAt: _updatedAt, - ...legacy - } = receipt; - await writeFile(receiptPath, JSON.stringify({ - ...legacy, - format: 'agent-bundle-install-receipt/1', - })); + const { state: _state, ...withoutState } = receipt; + await writeFile(receiptPath, JSON.stringify(withoutState)); await mkdir(currentStateRoot, { recursive: true }); await writeFile(currentSentinel, 'unrelated\n'); @@ -1007,7 +1003,6 @@ it('never derives legacy receipt purge ownership from the current environment', }); const remnant = await readInstallReceipt(destination); expect(remnant?.state).toBeUndefined(); - expect(remnant?.stateRoot).toBeUndefined(); const purged = await uninstallBundle({ ...options, @@ -1022,35 +1017,6 @@ it('never derives legacy receipt purge ownership from the current environment', }); expect(await readFile(currentSentinel, 'utf8')).toBe('unrelated\n'); expect(await readFile(join(originalStateRoot, 'state.sqlite'), 'utf8')).toBe('original\n'); - - // The #642 compatibility receipt remains authoritative only for the exact derived root it recorded. - await installBundle({ ...options, environment: originalEnvironment }); - const currentReceipt = JSON.parse(await readFile(receiptPath, 'utf8')) as Record; - const { state: _currentState, ...recordedLegacy } = currentReceipt; - await writeFile(receiptPath, JSON.stringify({ - ...recordedLegacy, - stateRoot: { root: originalStateRoot, source: 'derived' }, - })); - const recordedPlan = await uninstallBundle({ - ...options, - confirmPurge: true, - environment: currentEnvironment, - plan: true, - purgeData: true, - }); - expect(recordedPlan.data).toMatchObject({ - outcome: 'purged', - paths: [originalStateRoot], - }); - expect(recordedPlan.removed.directories).not.toContain(currentStateRoot); - await uninstallBundle({ - ...options, - confirmPurge: true, - environment: currentEnvironment, - purgeData: true, - }); - await expect(readFile(join(originalStateRoot, 'state.sqlite'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); - expect(await readFile(currentSentinel, 'utf8')).toBe('unrelated\n'); } finally { await removeTree(fixture.cleanupRoot); } @@ -1418,15 +1384,17 @@ it.each([ }); // The cache copy and plugins/data/ are scope-less: while the project scope still uses them, purging // from the user scope is refused before any host verb runs. + const dataDirectory = join(hostRoot, 'plugins', 'data', 'uninstall-fixture@uninstall-fixture-marketplace'); await cp(fixture.bundleRoot, installPath, { recursive: true }); - await mkdir(join(installPath, 'state'), { recursive: true }); - await writeFile(join(installPath, 'state', 'plugin.sqlite'), 'durable\n'); + await mkdir(dataDirectory, { recursive: true }); + await writeFile(join(dataDirectory, 'notes.txt'), 'data\n'); const sharedPurge = await failureOf(uninstallBundle({ ...options, commandRunner: scoped.runner, confirmPurge: true, purgeData: true })); expect(sharedPurge.diagnostics[0]).toMatchObject({ code: 'AB7008', target: 'claude' }); expect(sharedPurge.diagnostics[0]?.message).toContain('(scope project)'); expect(scoped.calls.map((call) => call.args.join(' '))).not.toContain(uninstall); - expect(await readFile(join(installPath, 'state', 'plugin.sqlite'), 'utf8')).toBe('durable\n'); + expect(await readFile(join(dataDirectory, 'notes.txt'), 'utf8')).toBe('data\n'); await removeTree(installPath); + await removeTree(dataDirectory); scoped.calls.length = 0; const otherScope = await uninstallBundle({ ...options, commandRunner: scoped.runner }); expect(otherScope.registrations.find((registration) => registration.kind === 'claude-marketplace')).toMatchObject({ @@ -1514,15 +1482,16 @@ it.each([ await writeFile(elsewhere, pluginOnlyReceipt('uninstall-fixture', '/elsewhere/project')); await installBundle(options); await cp(fixture.bundleRoot, installPath, { recursive: true }); - await mkdir(join(installPath, 'state'), { recursive: true }); - await writeFile(join(installPath, 'state', 'plugin.sqlite'), 'durable\n'); + await mkdir(dataDirectory, { recursive: true }); + await writeFile(join(dataDirectory, 'notes.txt'), 'data\n'); calls.length = 0; const receiptSharedPurge = await failureOf(uninstallBundle({ ...options, confirmPurge: true, purgeData: true })); expect(receiptSharedPurge.diagnostics[0]).toMatchObject({ code: 'AB7008', target: 'claude' }); expect(receiptSharedPurge.diagnostics[0]?.message).toContain(`receipt ${elsewhere} (scope project in /elsewhere/project)`); expect(calls.map((call) => call.args.join(' '))).not.toContain(uninstall); - expect(await readFile(join(installPath, 'state', 'plugin.sqlite'), 'utf8')).toBe('durable\n'); + expect(await readFile(join(dataDirectory, 'notes.txt'), 'utf8')).toBe('data\n'); await removeTree(installPath); + await removeTree(dataDirectory); calls.length = 0; // --plan announces the move without writing it. const planMove = await uninstallBundle({ ...options, plan: true }); @@ -1558,11 +1527,12 @@ it.each([ await writeFile(otherPlugin, pluginOnlyReceipt('other-fixture', '/elsewhere/other')); await installBundle(options); await cp(fixture.bundleRoot, installPath, { recursive: true }); - await mkdir(join(installPath, 'state'), { recursive: true }); - await writeFile(join(installPath, 'state', 'plugin.sqlite'), 'durable\n'); + await mkdir(dataDirectory, { recursive: true }); + await writeFile(join(dataDirectory, 'notes.txt'), 'data\n'); calls.length = 0; const byOtherReceipt = await uninstallBundle({ ...options, confirmPurge: true, purgeData: true }); - expect(byOtherReceipt).toMatchObject({ data: { outcome: 'purged' }, state: 'uninstalled' }); + expect(byOtherReceipt).toMatchObject({ data: { outcome: 'purged', paths: [dataDirectory] }, state: 'uninstalled' }); + await expect(readdir(dataDirectory)).rejects.toMatchObject({ code: 'ENOENT' }); expect(byOtherReceipt.registrations.find((registration) => registration.kind === 'claude-marketplace')).toMatchObject({ action: 'retained', detail: expect.stringContaining(`receipt ${otherPlugin}`), @@ -1598,16 +1568,17 @@ it.each([ { projectPath: '/elsewhere/by-hand', scope: 'project' }, ] })); await cp(fixture.bundleRoot, installPath, { recursive: true }); - await mkdir(join(installPath, 'state'), { recursive: true }); - await writeFile(join(installPath, 'state', 'plugin.sqlite'), 'durable\n'); + await mkdir(dataDirectory, { recursive: true }); + await writeFile(join(dataDirectory, 'notes.txt'), 'data\n'); calls.length = 0; const registrySharedPurge = await failureOf(uninstallBundle({ ...options, confirmPurge: true, purgeData: true })); expect(registrySharedPurge.diagnostics[0]).toMatchObject({ code: 'AB7008', target: 'claude' }); expect(registrySharedPurge.diagnostics[0]?.message) .toContain('uninstall-fixture@uninstall-fixture-marketplace (scope project in /elsewhere/by-hand, per plugins/installed_plugins.json)'); expect(calls.map((call) => call.args.join(' '))).not.toContain(uninstall); - expect(await readFile(join(installPath, 'state', 'plugin.sqlite'), 'utf8')).toBe('durable\n'); + expect(await readFile(join(dataDirectory, 'notes.txt'), 'utf8')).toBe('data\n'); await removeTree(installPath); + await removeTree(dataDirectory); calls.length = 0; const byRegistry = await uninstallBundle(options); expect(byRegistry.registrations.find((registration) => registration.kind === 'claude-marketplace')).toMatchObject({ @@ -1627,11 +1598,11 @@ it.each([ 'uninstall-fixture@uninstall-fixture-marketplace': [{ scope: 'user' }], })); await cp(fixture.bundleRoot, installPath, { recursive: true }); - await mkdir(join(installPath, 'state'), { recursive: true }); - await writeFile(join(installPath, 'state', 'plugin.sqlite'), 'durable\n'); + await mkdir(dataDirectory, { recursive: true }); + await writeFile(join(dataDirectory, 'notes.txt'), 'data\n'); calls.length = 0; const byOtherRegistered = await uninstallBundle({ ...options, confirmPurge: true, purgeData: true }); - expect(byOtherRegistered).toMatchObject({ data: { outcome: 'purged' }, state: 'uninstalled' }); + expect(byOtherRegistered).toMatchObject({ data: { outcome: 'purged', paths: [dataDirectory] }, state: 'uninstalled' }); expect(byOtherRegistered.registrations.find((registration) => registration.kind === 'claude-marketplace')).toMatchObject({ action: 'retained', detail: expect.stringContaining('other-fixture@uninstall-fixture-marketplace (scope local in /elsewhere/other, per plugins/installed_plugins.json)'), @@ -1729,30 +1700,27 @@ it('purges Claude durable state only when confirmed and reports the host-retaine await mkdir(dataDirectory, { recursive: true }); await writeFile(join(dataDirectory, 'notes.txt'), 'data\n'); + // The host-cached tree, an in-tree state/ included, belongs to Claude; only plugins/data// is a data path. const kept = await uninstallBundle({ ...options, plan: true }); - expect(kept.data).toMatchObject({ - outcome: 'retained-by-host', - paths: [join(installPath, 'state'), dataDirectory], - policy: 'keep', - }); + expect(kept.data).toMatchObject({ outcome: 'retained-by-host', paths: [dataDirectory], policy: 'keep' }); expect(kept.data.detail).toContain('--keep-data'); - // The plan and the run report the purged state trees as the directories they are, never as files. + // The plan and the run report the purged data directory as the directory it is, never as a file. const purgePlan = await uninstallBundle({ ...options, confirmPurge: true, plan: true, purgeData: true }); - expect(purgePlan.removed.directories.slice(0, 2)).toEqual([join(installPath, 'state'), dataDirectory]); + expect(purgePlan.removed.directories[0]).toBe(dataDirectory); expect(purgePlan.removed.files).toEqual([expect.stringContaining('uninstall-fixture.uninstall-fixture-marketplace.user.json')]); const purged = await uninstallBundle({ ...options, confirmPurge: true, purgeData: true }); - expect(purged.data).toMatchObject({ outcome: 'purged', paths: [join(installPath, 'state'), dataDirectory], policy: 'purge' }); - expect(purged.removed.directories.slice(0, 2)).toEqual([join(installPath, 'state'), dataDirectory]); + expect(purged.data).toMatchObject({ outcome: 'purged', paths: [dataDirectory], policy: 'purge' }); + expect(purged.removed.directories[0]).toBe(dataDirectory); expect(purged.removed.files).toEqual([expect.stringContaining('uninstall-fixture.uninstall-fixture-marketplace.user.json')]); - await expect(readdir(join(installPath, 'state'))).rejects.toMatchObject({ code: 'ENOENT' }); + expect(await readFile(join(installPath, 'state', 'plugin.sqlite'), 'utf8')).toBe('durable\n'); await expect(readdir(dataDirectory)).rejects.toMatchObject({ code: 'ENOENT' }); } finally { await removeTree(fixture.cleanupRoot); } }); -it('never derives legacy host-receipt purge ownership from the current environment', async () => { +it('never derives host-receipt purge ownership from the current environment without a state block', 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'); @@ -1791,8 +1759,8 @@ it('never derives legacy host-receipt purge ownership from the current environme await installBundle({ ...options, environment: originalEnvironment }); await cp(fixture.bundleRoot, installPath, { recursive: true }); const receipt = JSON.parse(await readFile(receiptPath, 'utf8')) as Record; - const { state: _state, stateRoot: _stateRoot, ...legacy } = receipt; - await writeFile(receiptPath, JSON.stringify(legacy)); + const { state: _state, ...withoutState } = receipt; + await writeFile(receiptPath, JSON.stringify(withoutState)); await mkdir(originalStateRoot, { recursive: true }); await writeFile(join(originalStateRoot, 'state.sqlite'), 'original\n'); await mkdir(currentStateRoot, { recursive: true }); @@ -1882,7 +1850,7 @@ it('reacquires Claude marketplace ownership after a keep-data remnant reinstall' } }); -it('keeps external Codex state while reporting in-tree state only for purge', async () => { +it('reports only external Codex state; an in-tree state/ belongs to the host cache and is never a purge path', async () => { const fixture = await createFixture('codex'); const hostRoot = join(fixture.cleanupRoot, 'codex-root'); let installed = false; @@ -1922,7 +1890,7 @@ it('keeps external Codex state while reporting in-tree state only for purge', as }); expect((await uninstallBundle({ ...options, confirmPurge: true, plan: true, purgeData: true })).data).toMatchObject({ outcome: 'purged', - paths: [stateRoot, join(installPath, 'state')], + paths: [stateRoot], policy: 'purge', }); const scoped = await failureOf(uninstallBundle({ ...options, scope: 'project' })); diff --git a/packages/workbench/src/discovery/discovery-client.ts b/packages/workbench/src/discovery/discovery-client.ts index 63d668b66..a1071f83c 100644 --- a/packages/workbench/src/discovery/discovery-client.ts +++ b/packages/workbench/src/discovery/discovery-client.ts @@ -131,7 +131,7 @@ const runtimeStatusSchema = z.discriminatedUnion('status', [ status: z.literal('available'), }), z.strictObject({ - status: z.enum(['failed', 'unavailable', 'unsupported']), + status: z.enum(['failed', 'unavailable']), }), ]); const findingShape = { diff --git a/website/docs/en/guide/distribution/installation.mdx b/website/docs/en/guide/distribution/installation.mdx index 49751e289..94f89dd8a 100644 --- a/website/docs/en/guide/distribution/installation.mdx +++ b/website/docs/en/guide/distribution/installation.mdx @@ -154,16 +154,17 @@ shares one replace policy. An identical copy is an `already-installed` no-op. A copy of the **same version whose content hash differs** is replaced automatically, so rebuilding without a version bump no longer needs an uninstall and `rm -rf`. A different version is refused with `AB7005` unless you pass `--replace`, and a foreign directory, one -this plugin's installer did not place, is refused either way. The standalone `install.mjs` hashes a -receipt-less destination from the files present there. That includes a legacy copy and a staged -marketplace plugin. When `agent-bundle.manifest.json` is present, the artifact hash uses that +this plugin's installer did not place, is refused either way. A copy placed before install +receipts existed carries no receipt, so it is foreign too, byte-identical or not: remove it by hand +and reinstall. The standalone `install.mjs` hashes a receipt-less destination from the files +present there, so the refusal can name the content comparison. When `agent-bundle.manifest.json` is present, the artifact hash uses that manifest's `files[]`, and a listed path missing from the artifact fails the run. A listed path missing from a foreign or staged destination is omitted from its hash, so the hashes differ and install refuses that copy instead of throwing `ENOENT`. Owned same-version copies can be repaired. Cursor and Amp copies carry an install receipt (`.agent-bundle-install.json`: plugin, version, host, content hash, owned files); replacement is in -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 +place and touches owned files only, never unowned entries such as a `state/` directory beside the +plugin. Artifact builds keep framework state under `~/.agent-bundle/state/-` instead (`AGENT_BUNDLE_STATE_ROOT` overrides that 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 @@ -245,7 +246,7 @@ variables each declares (`AB7331`, informational), never a name or a value. # exact paths, nothing changes npx agent-bundle uninstall cursor --from artifact --plan npx agent-bundle uninstall amp --from artifact --scope user --plan -# receipt-owned files; legacy or in-place state/ kept +# receipt-owned files; unowned entries and recorded state roots kept npx agent-bundle uninstall cursor --from artifact # claude plugin uninstall --keep-data + marketplace remove npx agent-bundle uninstall claude --from artifact @@ -260,17 +261,18 @@ created, the host registrations it performed, and timestamps. Cursor local copie directory; Claude, Codex, and Cursor marketplace-mode installs keep theirs under `/agent-bundle/receipts/`. `uninstall` removes exactly what the receipt owns and reverses exactly the registrations it recorded, never anything else; unowned entries are listed -as retained. The effective framework state root, derived web-data, legacy `state/`, and for a -Cursor copy of an Agent Plugins pack the `PLUGIN_DATA` directory the receipt records are kept unless +as retained, a `state/` directory beside the plugin included. The framework state roots the +receipt records with ownership evidence, derived web-data, and for a Cursor copy of an Agent Plugins +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 receipt (`AB7009`) or a content mismatch -(`AB7007`) is refused unless `--force`; a directory that belongs to another plugin is refused -regardless; a second run is a `not-installed` no-op. Receipts written before format 2 are read with -their lifecycle fields synthesized and diagnosed (`AB7329`), never rejected. When such a receipt -records neither a state location nor ownership, a root resolved from the current environment or home -is listed as unproven and retained; `--keep-data` cannot turn that observation into later purge -authority. +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 +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 +environment or home is listed as unproven and retained; `--keep-data` cannot turn that observation +into later purge authority. The host-install proofs snapshot an isolated home before install and after uninstall: byte-identical for Cursor local and the portable Agent Plugins package, and zero Agent Bundle residue plus an @@ -297,7 +299,7 @@ Claude Code lists the copy but refused to load it, its `claude plugin list --jso which Doctor reports verbatim in place of `current`, because a refused copy contributes no hooks, MCP servers, or skills to a session. The installer reads the same array: `agent-bundle install claude` fails with `AB7006` when the freshly installed (or byte-identical existing) copy carries `errors`, instead of reporting `installed`. Doctor also -inventories the receipt store under each host root and cross-checks it against the host (`AB7328`, `AB7329`). +inventories the receipt store under each host root and cross-checks it against the host (`AB7328`). For Claude, Doctor also reads each row's `enabled` flag and runs the Claude Code developer validator. A copy listed with `enabled: false` (switched off with `claude plugin disable` or the @@ -314,9 +316,7 @@ registration proof and the rows' `errors` already hold that verdict. | --- | --- | --- | | `AB7330` | info | The bundle's lifecycle stage on this host and its four observations; unobservable stages (a live session's loaded plugins on every host, Cursor's server-assigned enabled state) are typed `unavailable`, never guessed. | | `AB7328` | warning | A store receipt records a registration the host no longer holds (orphaned), or the receipt store cannot be read; `agent-bundle uninstall` consumes an orphaned receipt. | -| `AB7329` | info | A receipt predates lifecycle receipts and was read with synthesized fields; rerun `install` once to rewrite it as format 2. | -| `AB7316` | warning | An installed bundle's effective or legacy state directory is not writable, or the directory or one of its `*.sqlite`, `-wal`, or `-shm` files cannot be read with filesystem metadata operations. Doctor inventories state by directory entry and metadata only; it never opens a database. | -| `AB7317` | info | A live event runtime implements the older strict protocol and does not expose runtime identity. | +| `AB7316` | warning | An installed bundle's effective state directory is not writable, or the directory or one of its `*.sqlite`, `-wal`, or `-shm` files cannot be read with filesystem metadata operations. Doctor inventories state by directory entry and metadata only; it never opens a database. | | `AB7318` | error | A live event runtime became unavailable, timed out, or returned an invalid status response during the bounded read-only identity probe. | | `AB7319` | error | A host tree resolved from `doctor --from` violates its pinned document schemas or process-free loader rules; the message retains the originating build-validator code. | | `AB7322` | info / error | Info when an installed Cursor plugin registers plugin-scoped hooks from its manifest and every command's script exists under the plugin root; error when the declared hooks file is missing or malformed, or an executed script is absent. | diff --git a/website/docs/en/reference/cli.mdx b/website/docs/en/reference/cli.mdx index d82f3c48b..9bd73c070 100644 --- a/website/docs/en/reference/cli.mdx +++ b/website/docs/en/reference/cli.mdx @@ -194,7 +194,8 @@ agent-bundle install [--from ] [--scope ] [--mode ] \ The emitted standalone `install.mjs` accepts the same `--replace`. Cursor copies carry an install receipt (`.agent-bundle-install.json`), replacement -touches owned files only, and `--replace` adopts a pre-receipt copy; Claude replacement runs +touches owned files only, and a directory without a receipt naming this plugin (a copy placed +before receipts existed included) is foreign, refused with or without `--replace`; Claude replacement runs `claude plugin uninstall --keep-data` before reinstalling. Codex replacement is add-only (`codex plugin add`) so plugin settings and nested MCP overrides survive. Disabled or unknown-enablement Codex replacements are refused (`AB7004`) because the native plugin CLI @@ -220,9 +221,9 @@ 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 or Amp). | | `--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, 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. | +| `--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 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. | +| `--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`). | | `--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 @@ -232,11 +233,13 @@ commit), or the host registrations (`claude plugin uninstall --scope /`); Codex keeps external state by default +orphaned for Claude's ~14-day grace period; a purge removes the owned framework state roots, +derived web-data, and `plugins/data//`); Codex keeps external state by default and removes it on a confirmed purge while `codex plugin remove` deletes the cached tree. The emitted `install.mjs` accepts `--uninstall` with `--mode`, `--keep-data`, `--purge-data --confirm-purge`, `--force`, and `--plan`. @@ -250,10 +253,10 @@ evidence authorizes deletion. The default `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. A supported older receipt with neither recorded state metadata -nor a recorded `stateRoot` cannot make the current environment or home a source of purge authority: -Doctor and uninstall report the observed root as `unrecorded` / unproven and retain it, including -after `--keep-data`. +recorded and judged independently. A receipt with no `state` block cannot make the current +environment or home a source of purge authority: Doctor and uninstall report the observed root as +`unrecorded` / unproven and retain it, including after `--keep-data`. Only +`agent-bundle-install-receipt/2` is read; a copy carrying an older receipt is foreign. ## doctor @@ -282,15 +285,14 @@ Agent Plugins install it proves the emitted installer's placeholder expansion, ` each observed (Claude and Codex `plugin list --json` rows and `enabled` flags, the Cursor local directory, the Cursor marketplace import cache) or typed `unavailable` with the reason no read-only surface exposes it (`AB7330`). It inventories the Agent Bundle receipt store under each host root -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. +and warns about receipts the host no longer honours (`AB7328`). A Cursor directory +`uninstall --keep-data` left behind with its remnant receipt is reported `missing` with an +`AB7307` info, not corrupt or foreign; without that receipt, a directory holding only `state/` is +corrupt (`AB7304`) and foreign (`AB7321`). 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. -For compatibility receipts with a recorded `stateRoot`, Doctor also lists that historical root when -the current environment resolves elsewhere. A pre-#640 `/state` is reported separately -and flagged with `AB7332`. +A `state/` directory beside the plugin is an ordinary unowned entry, not durable state. ## validate diff --git a/website/docs/zh/guide/distribution/installation.mdx b/website/docs/zh/guide/distribution/installation.mdx index 71eb0e9b6..b7343b0e0 100644 --- a/website/docs/zh/guide/distribution/installation.mdx +++ b/website/docs/zh/guide/distribution/installation.mdx @@ -126,11 +126,12 @@ Amp 不属于开发期安装宿主;请使用其有归属回执的 `agent-bundl 每个输出的安装器,`agent-bundle install ` 与独立的 `install.mjs`,共用同一套替换策略。 内容完全相同的副本是 `already-installed` 空操作。**版本相同但内容哈希不同**的副本会被自动替换,因此不升版本 地重建不再需要卸载加 `rm -rf`。版本不同则以 `AB7005` 拒绝,除非传入 `--replace`;外来目录 -(不是本插件安装器放置的)无论如何都会被拒绝。独立的 `install.mjs` 对没有回执的目标目录按其中实际存在的文件计算哈希, -包括回执出现之前的副本和已暂存的 marketplace 插件。存在 `agent-bundle.manifest.json` 时,产物哈希使用该清单的 `files[]`, +(不是本插件安装器放置的)无论如何都会被拒绝。安装回执出现之前放置的副本没有回执,因此同样是外来目录, +无论字节是否一致:手动删除后重新安装。独立的 `install.mjs` 对没有回执的目标目录按其中实际存在的文件计算哈希, +以便拒绝信息能给出内容比对。存在 `agent-bundle.manifest.json` 时,产物哈希使用该清单的 `files[]`, 清单列出而产物缺失的路径会使安装失败。外来或已暂存的目标目录中缺少的清单路径不进入目标哈希,因此两边哈希不同,安装拒绝该副本,而不是抛出 `ENOENT`。属于本安装器的同版本副本可以修复。Cursor 与 Amp 副本携带安装回执(`.agent-bundle-install.json`: -插件、版本、宿主、内容哈希、归属文件);替换就地进行,只触碰归属文件,绝不动旧版或就地的 `state/` 之类的非归属条目, -`--replace` 会接管回执出现之前的副本。本发行版构建的产物把框架状态放在 +插件、版本、宿主、内容哈希、归属文件);替换就地进行,只触碰归属文件,绝不动插件旁的 `state/` 目录之类的非归属条目。 +产物构建把框架状态放在 `~/.agent-bundle/state/-`(`AGENT_BUNDLE_STATE_ROOT` 覆盖该位置)。`uninstall` 配合 `--purge-data --confirm-purge` 只会删除回执证明归该安装独占的根;预先存在、共享、无标记或其他 无法证明归属的覆盖根都会保留。Claude 的替换先运行 `claude plugin uninstall --keep-data` 再重新安装, @@ -195,7 +196,7 @@ hook 包装器、产物 CLI `bin/.mjs`,在启动时按同一条规则读 # 确切路径,不做改动 npx agent-bundle uninstall cursor --from artifact --plan npx agent-bundle uninstall amp --from artifact --scope user --plan -# 回执归属的文件;保留旧版或就地的 state/ +# 回执归属的文件;保留非归属条目与回执记录的状态根 npx agent-bundle uninstall cursor --from artifact # claude plugin uninstall --keep-data + marketplace remove npx agent-bundle uninstall claude --from artifact @@ -207,13 +208,12 @@ node artifact/install.mjs --uninstall [--mode marketplace] 目录、安装器创建的宿主目录、执行过的宿主注册,以及时间戳。Cursor 本地副本与 Amp 项目/系统副本以 `.agent-bundle-install.json` 携带它;Claude、Codex 与 Cursor 市场模式的安装把回执放在 `<宿主根目录>/agent-bundle/receipts/` 下。`uninstall` 只删除回执 -归属的内容,只撤销它记录的注册,绝不多删;非归属条目会被列为保留。有效框架状态根、推导出的 web-data、旧版 `state/`,以及 Agent Plugins +归属的内容,只撤销它记录的注册,绝不多删;非归属条目会被列为保留,插件旁的 `state/` 目录也在其中。回执带归属证据记录的框架状态根、推导出的 web-data,以及 Agent Plugins 包的 Cursor 副本在回执中记录的 `PLUGIN_DATA` 目录,除非传入 `--purge-data --confirm-purge` 否则保留,且结果如实说明宿主自行决定而 Agent Bundle 无法左右的部分(Claude 把缓存 -副本标为 orphaned 并保留约 14 天;Codex 删除缓存树且没有 keep-data 选项)。缺少回执(`AB7009`)或内容不匹配 -(`AB7007`)会被拒绝,除非 `--force`;属于另一个插件的目录无论如何都被拒绝;再次运行是 `not-installed` 空操作。 -格式 2 之前写入的回执会在补全生命周期字段后读取并给出诊断(`AB7329`),绝不被拒绝。如果这种回执既未记录 -状态位置也未记录归属,从当前环境或 home 解析出的根只会列为无法证明并保留;`--keep-data` 也不能把这次观察变成 -以后 purge 的权限。 +副本标为 orphaned 并保留约 14 天;Codex 删除缓存树且没有 keep-data 选项)。宿主已注册的安装缺少仓库回执(`AB7009`)或内容不匹配 +(`AB7007`)会被拒绝,除非 `--force`;没有回执的 Cursor 目录,或属于另一个插件的目录,是外来目录,无论如何都被拒绝(`AB7007`); +再次运行是 `not-installed` 空操作。只读取格式 2 的回执;携带更早回执的副本是外来目录。如果回执未记录状态归属, +从当前环境或 home 解析出的根只会列为无法证明并保留;`--keep-data` 也不能把这次观察变成以后 purge 的权限。 宿主安装证明会在安装前与卸载后对隔离的 home 做快照:Cursor 本地与可移植 Agent Plugins 包做到字节一致;Claude 与 Codex 做到零 Agent Bundle 残留,并逐项列出宿主自有的记录文件。 @@ -235,7 +235,7 @@ registered → enabled → active,每一阶段要么从宿主观察得到, Claude Code 列出了该副本却拒绝加载它,它在 `claude plugin list --json` 中的行带有 `errors` 数组,Doctor 会原样报告 该数组而不是 `current`,因为被拒绝的副本不会为会话提供任何 hook、MCP 服务器或技能。安装器读取同一数组:当刚安装的 (或字节完全相同的既有)副本带有 `errors` 时,`agent-bundle install claude` 以 `AB7006` 失败,而不是报告 `installed`。 -Doctor 还会清点每个宿主根目录下的回执仓库并与宿主交叉核对(`AB7328`、`AB7329`)。 +Doctor 还会清点每个宿主根目录下的回执仓库并与宿主交叉核对(`AB7328`)。 对 Claude,Doctor 还会读取每一行的 `enabled` 标志并运行 Claude Code 开发者校验器。被列为 `enabled: false` 的副本(用 `claude plugin disable` 或 `/plugin` 菜单关闭)在清点中报告为 `disabled`,在 `--from` 比对中保留其内容 @@ -249,9 +249,7 @@ Doctor 还会清点每个宿主根目录下的回执仓库并与宿主交叉核 | --- | --- | --- | | `AB7330` | info | 该捆绑包在此宿主上的生命周期阶段及四项观察;无法观察的阶段(任一宿主的活跃会话加载了哪些插件、Cursor 由服务端分配的启用状态)被类型化为 `unavailable`,绝不猜测。 | | `AB7328` | warning | 某份仓库回执记录的注册宿主已不再持有(孤立),或回执仓库无法读取;`agent-bundle uninstall` 会消费孤立的回执。 | -| `AB7329` | info | 某份回执早于生命周期回执,读取时补全了字段;再运行一次 `install` 即可将其重写为格式 2。 | -| `AB7316` | warning | 某个已安装捆绑包的有效或旧版状态目录不可写,或该目录及其某个 `*.sqlite`、`-wal`、`-shm` 文件无法通过文件系统元数据操作读取。Doctor 只按目录条目与元数据清点状态;它绝不打开数据库。 | -| `AB7317` | info | 某个活跃的事件运行时实现的是较旧的严格协议,不暴露运行时身份。 | +| `AB7316` | warning | 某个已安装捆绑包的有效状态目录不可写,或该目录及其某个 `*.sqlite`、`-wal`、`-shm` 文件无法通过文件系统元数据操作读取。Doctor 只按目录条目与元数据清点状态;它绝不打开数据库。 | | `AB7318` | error | 在有界的只读身份探测过程中,某个活跃的事件运行时变为不可用、超时,或返回了无效的状态响应。 | | `AB7319` | error | 由 `doctor --from` 解析出的宿主目录树违反了它被固定的文档 schema 或无进程加载器规则;消息中保留原始的构建校验器代码。 | | `AB7322` | info / error | 已安装的 Cursor 插件从清单注册了插件级 hook 且每条命令的脚本都存在于插件根之下时为 info;声明的 hooks 文件缺失或格式错误、或被执行的脚本不存在时为 error。 | diff --git a/website/docs/zh/reference/cli.mdx b/website/docs/zh/reference/cli.mdx index f693981b6..9a4dd218e 100644 --- a/website/docs/zh/reference/cli.mdx +++ b/website/docs/zh/reference/cli.mdx @@ -183,7 +183,8 @@ agent-bundle install [--from ] [--scope ] [--mode ] \ | `--replace` | 关闭 | 即使版本不同,也替换该插件已有的 agent-bundle 安装。不带它时,内容完全相同的副本是 `already-installed` 空操作,版本相同但内容哈希不同的副本会被自动替换,版本不同则为 `AB7005`。外来目录总是被拒绝(`AB7005`)。 | 输出的独立 `install.mjs` 接受同样的 `--replace`。Cursor 副本携带安装回执 -(`.agent-bundle-install.json`),替换只触碰归属文件,`--replace` 会接管回执出现之前的副本;Claude 的替换 +(`.agent-bundle-install.json`),替换只触碰归属文件;没有指向本插件回执的目录(包括回执出现之前放置的副本) +是外来目录,带不带 `--replace` 都会被拒绝;Claude 的替换 先运行 `claude plugin uninstall --keep-data` 再重新安装。Codex 的替换只执行 `codex plugin add`,以便保留插件设置与嵌套 MCP 覆盖。已禁用或启用状态未知的 Codex 替换会被拒绝(`AB7004`),因为原生 plugin CLI 没有可保留设置的合格更新 API。 每次安装都会写入生命周期回执(格式 `agent-bundle-install-receipt/2`:版本、内容哈希、模式、作用域、归属路径、 @@ -205,9 +206,9 @@ agent-bundle uninstall [--from ] [--scope ] [--mode ] | `--from ` | `process.cwd()` | 其 `agent-bundle.manifest.json` 用于识别插件(名称、版本、市场)的组合根目录,读取方式与 `install` 完全相同(相同条件下为 `AB7001`)。 | | `--scope ` | `user` | 安装时使用的作用域(Claude 或 Amp)。 | | `--mode ` | `local` | 仅限 Cursor:卸载 `local` 副本或已暂存的 `marketplace` 仓库。 | -| `--keep-data` | 开启 | 保留回执记录的所有框架状态根、推导出的 web-data、旧版 `state/`,以及回执记录的 Cursor `PLUGIN_DATA` 目录。这是默认行为;该标志只是显式声明,并保留归属回执供以后 purge。 | +| `--keep-data` | 开启 | 保留回执记录的所有框架状态根、推导出的 web-data,以及回执记录的 Cursor `PLUGIN_DATA` 目录。这是默认行为;该标志只是显式声明,并保留归属回执供以后 purge。 | | `--purge-data` | 关闭 | 只删除回执记录且由该安装独占的持久数据根。没有 `--confirm-purge` 时被拒绝(`AB7008`);共享、外部管理、无标记、外来标记及其他无法证明归属的根都会被保留并列出。 | -| `--force` | 关闭 | 在没有回执(旧版 Cursor 副本、仅宿主侧的安装)或归属内容、版本、暂存 `HEAD` 与回执不再匹配时继续。回执或清单指向另一个插件时无论如何都会被拒绝。 | +| `--force` | 关闭 | 在宿主已注册的安装(Claude/Codex、暂存的 Cursor 市场)缺少仓库回执,或归属内容、版本、暂存 `HEAD` 与回执不再匹配时继续。没有回执的 Cursor 本地目录,以及回执或清单指向另一个插件的目录,是外来目录,无论如何都会被拒绝(`AB7007`)。 | | `--plan` | 关闭 | 打印将被删除的确切路径与宿主注册,不做任何改动。 | uninstall 只删除回执归属的内容:记录的文件与安装器创建的目录(Cursor 本地,包括安装本身创建的 @@ -215,10 +216,11 @@ uninstall 只删除回执归属的内容:记录的文件与安装器创建的 (`claude plugin uninstall --scope --keep-data` + `claude plugin marketplace remove`, `codex plugin remove` + `codex plugin marketplace remove`,若另一个已安装插件仍在使用该市场,则保留市场, 包括仅记录在 Claude 的 `plugins/installed_plugins.json` 注册表中、位于另一作用域或另一项目的 Claude 安装)。 -非归属条目被保留并列出;缺少回执为 `AB7009`,不匹配为 `AB7007`,除非 `--force`;再次运行是 `not-installed` +非归属条目被保留并列出,插件旁的 `state/` 目录也在其中;缺少仓库回执为 `AB7009`,不匹配为 `AB7007`,除非 `--force`; +没有回执的 Cursor 本地目录即使带 `--force` 也是外来目录(`AB7007`);再次运行是 `not-installed` 空操作。类型化的 `data.outcome` 按宿主如实说明持久状态的去向:Cursor 为 `kept` / `purged` / `absent`; -Claude 为 `retained-by-host`(缓存副本在 Claude 约 14 天的宽限期内被标为 orphaned;purge 会删除有效框架状态根、 -推导出的 web-data、旧版 `state/` 与 `plugins/data//`);Codex 默认保留外部状态,在确认 purge 时删除它, +Claude 为 `retained-by-host`(缓存副本在 Claude 约 14 天的宽限期内被标为 orphaned;purge 会删除归属的框架状态根、 +推导出的 web-data 与 `plugins/data//`);Codex 默认保留外部状态,在确认 purge 时删除它, 而 `codex plugin remove` 会删除缓存树。输出的 `install.mjs` 接受 `--uninstall`,并支持 `--mode`、`--keep-data`、`--purge-data --confirm-purge`、`--force` 与 `--plan`。 @@ -228,8 +230,9 @@ Claude 为 `retained-by-host`(缓存副本在 Claude 约 14 天的宽限期内 `$XDG_STATE_HOME/agent-bundle/-`)命名空间按构造归该安装独占。显式 `AGENT_BUNDLE_STATE_ROOT` 只有在安装时原本不存在、由安装器创建并写入安装身份标记时才归该安装所有。 预先存在的目录绝不会仅因服务器声明或卸载时的当前环境指向它而被递归删除。多个服务器与多个根会分别记录、分别判断。 -如果某份仍受支持的旧回执既没有状态元数据,也没有记录 `stateRoot`,当前环境或 home 就不能提供 purge 权限: +如果回执没有 `state` 块,当前环境或 home 就不能提供 purge 权限: Doctor 与 uninstall 会把观察到的根报告为 `unrecorded` / 无法证明并予以保留,即使之前运行过 `--keep-data` 也一样。 +只读取 `agent-bundle-install-receipt/2`;携带更早回执的副本是外来目录。 ## doctor @@ -252,13 +255,12 @@ Doctor 与 uninstall 会把观察到的根报告为 `unrecorded` / 无法证明 `expanded`、`unexpanded`(Cursor 3.18.25 无法启动的规范形式)或 `drifted`(`AB7326`)。带 `--from` 时,它还按宿主报告 生命周期阶段,placed → registered → enabled → active,每一阶段要么被观察到(Claude 与 Codex 的 `plugin list --json` 行及其 `enabled` 标志、Cursor 本地目录、Cursor 市场导入缓存),要么被类型化为 `unavailable` 并给出没有只读表面能暴露它 -的原因(`AB7330`)。它还清点每个宿主根目录下的 Agent Bundle 回执仓库,对宿主已不再认可的回执发出警告(`AB7328`),并把 -格式 2 之前写入的回执报告为已迁移(`AB7329`)。仅包含 `uninstall --keep-data` 所保留运行时状态的 Cursor 目录会以 -`AB7307` info 报告为 `missing`,而不是 corrupt 或 foreign。 +的原因(`AB7330`)。它还清点每个宿主根目录下的 Agent Bundle 回执仓库,对宿主已不再认可的回执发出警告(`AB7328`)。 +`uninstall --keep-data` 连同残留回执留下的 Cursor 目录会以 `AB7307` info 报告为 `missing`,而不是 corrupt 或 foreign; +没有该回执时,仅包含 `state/` 的目录既是 corrupt(`AB7304`)也是 foreign(`AB7321`)。 对于每份已安装副本,Doctor 会报告每个服务器对应的框架状态根、其 `native` 或 `derived` 来源、回执归属 (`derived`、`marker`、`unowned` 或 `unrecorded`)、当前证据是否允许 purge、使用它的服务器、是否存在以及是否可写。 -对于记录了 `stateRoot` 的兼容回执,如果当前环境解析到别处,Doctor 还会单独列出这个历史根。 -升级 #640 之前留下的 `/state` 会单独报告,并以 `AB7332` 标记。 +插件旁的 `state/` 目录是普通的非归属条目,不是持久状态。 ## validate