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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/foreign-destination-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
5 changes: 5 additions & 0 deletions .changeset/remove-legacy-install-state.md
Original file line number Diff line number Diff line change
@@ -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 `<plugin root>/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)
135 changes: 68 additions & 67 deletions docs/diagnostics.md

Large diffs are not rendered by default.

25 changes: 13 additions & 12 deletions docs/framework-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <host>
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 <host>
[--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
Expand All @@ -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 `<host root>/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
Expand All @@ -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`
Expand Down
59 changes: 28 additions & 31 deletions packages/agent-bundle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <host>` | 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 <host>` | 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 <host>` | 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. |
Expand Down Expand Up @@ -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 <plugin>@<marketplace> --scope <scope> --keep-data`
Expand All @@ -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 <name>@<version> content <hash> vs
Expand Down Expand Up @@ -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.
Expand All @@ -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/<id>/`); 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/<id>/`); 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
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-bundle/src/contracts/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 4 additions & 2 deletions packages/agent-bundle/src/events/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ export type RequestEventRuntimeStatusOptions = Readonly<{

export type EventRuntimeStatusResult =
| Readonly<EventRuntimeStatus & { readonly status: 'available' }>
| 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);
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 4 additions & 4 deletions packages/agent-bundle/src/install/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,13 +129,13 @@ export const registerLifecycleCommands = (program: Command, options: LifecycleCo
)
.option('--scope <scope>', 'Host install scope', installScope, 'user')
.option('--mode <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');
Expand Down
Loading
Loading