Skip to content

Commit 77682d5

Browse files
fix(install): fail-closed marketplace ownership, shared-purge refusal, unconditional receipt inventory
Address codex review on #452: - install claims a Claude/Codex marketplace only when it was absent before the install (or a prior receipt already claimed it); pre-existing or unreadable marketplaces are never recorded as owned - uninstall scans live host inventory plus every stored receipt for marketplace dependents and retains the marketplace when any exist or the inventory is unknown; Claude --purge-data refuses (AB7008) when the same plugin is installed at another scope or dependents are unknown - Claude project/local receipts are keyed by project root so parallel projects no longer overwrite each other's receipt - doctor inventories store receipts even when the host probe is unavailable - uninstall --plan for Cursor local simulates the prune precisely; --keep-data reruns on a remnant are a not-installed no-op - Cursor marketplace imported detection uses the receipt commit even after the staged repo is gone
1 parent 699c0ff commit 77682d5

10 files changed

Lines changed: 543 additions & 134 deletions

File tree

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
"agent-bundle": minor
2+
"agent-bundle": patch
33
---
44

55
Add `agent-bundle uninstall <host> [--from <bundle-dir>] [--scope <scope>] [--mode local|marketplace] [--keep-data | --purge-data --confirm-purge] [--force] [--plan] [--json]`, the package-relative installer bin's `uninstall <host>`, and the emitted standalone `install.mjs --uninstall`: the receipt-owned reverse of `install`. Uninstall removes exactly the receipt's files and installer-created directories (Cursor local, including the `~/.cursor/plugins[/local]` directories the install created), the staged marketplace repository after its `HEAD` matches the recorded commit (Cursor `--mode marketplace`), or the recorded host registrations (`claude plugin uninstall --keep-data` + `claude plugin marketplace remove`, `codex plugin remove` + `codex plugin marketplace remove`, the marketplace retained while another installed plugin uses it) — and nothing else. Durable runtime state (`state/`) is kept unless `--purge-data --confirm-purge` (`AB7008` without confirmation) with a typed per-host `data.outcome` (`kept`/`purged`/`absent`, Claude `retained-by-host`, Codex `removed-by-host`/`unavailable`); a missing receipt is `AB7006` and an owned-content, version, or `HEAD` mismatch is `AB7007` unless `--force`; foreign directories are refused regardless; `--plan` prints the exact paths and host verbs without changing anything; a rerun is a `not-installed` no-op. Install receipts move to format `agent-bundle-install-receipt/2` as the single lifecycle source of truth (mode, scope, registrations, created host directories, `updatedAt`), Claude/Codex/Cursor-marketplace installs write store receipts under `<host root>/agent-bundle/receipts/`, and format 1 receipts are read with synthesized fields and diagnosed (`AB7327`), never rejected. `agent-bundle doctor --from` reports the lifecycle stage per host (placed → registered → enabled → active, unobservable stages typed `unavailable`; `AB7325`), inventories store receipts and flags orphaned ones (`AB7326`), and explains a Cursor directory holding only preserved runtime state as `missing` (`AB7307`) instead of foreign. Host capability tables gain dated `lifecycle` rows and every adapter revision advances (#452)

‎docs/diagnostics.md‎

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -734,7 +734,16 @@ committed tree, so theirs live in an Agent Bundle-owned store,
734734
`<host root>/agent-bundle/receipts/<plugin>.<scope|marketplace>.json`
735735
(`~/.claude` or `$CLAUDE_CONFIG_DIR`, `~/.codex` or `$CODEX_HOME`,
736736
`~/.cursor`), with `files: []` — they own no files, only the registrations and
737-
the content hash. A format 1 receipt (written by #420) is read with those
737+
the content hash. A Claude `project` / `local` scope registration belongs to
738+
the working directory the host verbs ran in (the bundle root), so those
739+
receipts are keyed `<plugin>.<scope>.<12-hex digest of projectRoot>.json` and
740+
record `projectRoot`: two projects installing the same plugin at the same scope
741+
are two receipts. The `<host>-marketplace` registration is recorded only when
742+
the install actually created it — `plugin marketplace list --json` did not list
743+
the marketplace beforehand (or the receipted install it replaces recorded it);
744+
a marketplace that already existed, or one whose state could not be read,
745+
is not claimed, and `uninstall` then retains it and says why. A format 1
746+
receipt (written by #420) is read with those
738747
fields synthesized (`mode: local`, `scope: user`, one `cursor-local-plugin`
739748
registration, no host directories) and reported as migrated (`AB7327`); an
740749
identical rerun of the installer rewrites it as format 2. A current-format
@@ -822,10 +831,13 @@ open issue). Every mutation is opt-in and bounded by the receipt:
822831
`claude plugin uninstall <id> --scope <scope> --keep-data` /
823832
`codex plugin remove <id>`, then `plugin marketplace remove <marketplace>`
824833
and removes the store receipt. Because `plugin marketplace remove` applies
825-
to every scope, the marketplace is `retained` when another installed plugin
826-
still names it, when the same plugin is installed at another Claude scope,
827-
or when the dependency re-read of `plugin list --json` fails (a failed read
828-
is not proof that nothing depends on it). A registration the host no longer holds is
834+
to every scope, the marketplace is `retained` when the receipt does not
835+
record Agent Bundle registering it (it pre-existed the install, or there is
836+
no receipt), when another installed plugin still names it, when another
837+
store receipt (another project's scoped install) records it, when the same
838+
plugin is installed at another Claude scope, or when `plugin marketplace
839+
list --json` / the dependency re-read of `plugin list --json` cannot be read
840+
(a failed read is not proof that nothing depends on it). A registration the host no longer holds is
829841
`already-absent`, so a receipt orphaned behind Agent Bundle's back is
830842
consumed without running any host verb.
831843

@@ -839,14 +851,20 @@ for its ~14-day grace period; a purge additionally removes `state/` and
839851
tree on `plugin remove`), and `unavailable` (Codex has no keep-data option; a
840852
staged Cursor marketplace holds no runtime state). `--plan` computes the same
841853
report — exact absolute paths, registrations, data decision — without opening a
842-
writer, and a second run after a successful uninstall is a `not-installed`
843-
no-op.
854+
writer; planned directories are exactly the ones the run would prune (purged
855+
`state/` first, then every owned directory that would be left empty), never a
856+
directory kept alive by retained state or unowned entries. A second run after a
857+
successful uninstall is a `not-installed` no-op. When `--keep-data` left
858+
`state/` behind under a Cursor local root, the remnant receipt written there
859+
stays in place (`receipt.status: 'remnant'`) and a rerun without
860+
`--purge-data` is the same `not-installed` no-op; only `--purge-data
861+
--confirm-purge` removes the preserved state and prunes the root.
844862

845863
| Code | Severity | Trigger | Recovery |
846864
| --- | --- | --- | --- |
847865
| `AB7006` | 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. |
848866
| `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, 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. |
849-
| `AB7008` | error | `--purge-data` without `--confirm-purge`, or `--purge-data` together with `--keep-data`. | Pass `--purge-data --confirm-purge` to delete durable state, or neither flag to keep it. |
867+
| `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 — the cached copy and `plugins/data/<id>/` are scope-less and still in use — or while `claude plugin list --json` cannot be re-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. |
850868

851869
The Cursor and portable host-install proofs (`tests/host-install-proof.test.ts`,
852870
`tests/packed-host-install-proof.test.ts`) snapshot the isolated home before

‎packages/agent-bundle/src/install/doctor.ts‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
installReceiptFile,
3939
installReceiptFormat,
4040
installReceiptStoreDirectory,
41+
isPreservedRuntimeRoot,
4142
isRemnantReceipt,
4243
isRuntimeStateRemnant,
4344
readInstallReceipt,
@@ -815,15 +816,26 @@ const cursorInventory = async (
815816
} catch {
816817
remnantReceipt = undefined;
817818
}
818-
const remnant = (remnantReceipt !== undefined && isRemnantReceipt(remnantReceipt)) || await isRuntimeStateRemnant(path);
819+
const stateOnly = await isRuntimeStateRemnant(path);
820+
const remnant = stateOnly || (remnantReceipt !== undefined && isRemnantReceipt(remnantReceipt));
819821
if (remnant) {
820822
const durableState = await inspectDurableState(path, 'cursor');
821823
if (durableState !== undefined) diagnostics.push(...durableState.diagnostics);
824+
// A remnant receipt may also guard unowned entries the uninstall retained: say so instead of
825+
// calling the directory state-only.
826+
const entries = stateOnly ? [] : (await readdir(path)).filter((name) => name !== installReceiptFile);
827+
const extras = entries.filter((name) => !isPreservedRuntimeRoot(name)).sort((left, right) => left.localeCompare(right));
822828
diagnostics.push(diagnostic(
823829
'AB7307',
824-
`Cursor plugin entry ${JSON.stringify(path)} holds only preserved runtime state (state/) from an earlier ` +
825-
'`uninstall --keep-data`; no plugin is installed there.',
826-
'Reinstall the plugin to use the preserved state, or run `agent-bundle uninstall cursor --purge-data --confirm-purge` to remove it.',
830+
extras.length === 0
831+
? `Cursor plugin entry ${JSON.stringify(path)} holds only preserved runtime state (state/) from an earlier ` +
832+
'`uninstall --keep-data`; no plugin is installed there.'
833+
: `Cursor plugin entry ${JSON.stringify(path)} holds no plugin: an earlier \`uninstall\` retained the unowned ` +
834+
`${extras.length === 1 ? 'entry' : 'entries'} ${extras.map((name) => JSON.stringify(name)).join(', ')}` +
835+
`${entries.some(isPreservedRuntimeRoot) ? ' beside preserved runtime state (state/)' : ''}.`,
836+
extras.length === 0
837+
? 'Reinstall the plugin to use the preserved state, or run `agent-bundle uninstall cursor --purge-data --confirm-purge` to remove it.'
838+
: 'Reinstall the plugin, or move the retained entries out and remove the directory by hand; `uninstall` never removes unowned entries.',
827839
'info',
828840
'cursor',
829841
));

‎packages/agent-bundle/src/install/install.ts‎

Lines changed: 78 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
createInstallReceipt,
1717
describeContentComparison,
1818
installReceiptFile,
19+
installReceiptScopeKey,
1920
installReceiptStorePath,
2021
isRemnantReceipt,
2122
isRuntimeStateRemnant,
@@ -440,14 +441,61 @@ export const publicHostRegistrations = (
440441
Object.freeze({ id, kind: 'codex-plugin' as const }),
441442
]);
442443

443-
/** Where the store receipt for a host-CLI install lives (`<host root>/agent-bundle/receipts/<plugin>.<scope>.json`). */
444+
/**
445+
* Where the store receipt for a host-CLI install lives:
446+
* `<host root>/agent-bundle/receipts/<plugin>.<scope>[.<project digest>].json`.
447+
* Claude `project` / `local` registrations belong to the working directory the
448+
* host verbs ran in (the bundle root), so each project keeps its own receipt.
449+
*/
444450
export const publicHostReceiptPath = (
445451
host: Exclude<InstallHost, 'cursor'>,
446452
plugin: string,
447453
scope: InstallScope,
448454
environment: Readonly<NodeJS.ProcessEnv>,
449455
home: string,
450-
): string => installReceiptStorePath(publicHostRoot(host, environment, home), plugin, scope);
456+
projectRoot?: string,
457+
): string => installReceiptStorePath(
458+
publicHostRoot(host, environment, home),
459+
plugin,
460+
installReceiptScopeKey(scope, projectRoot),
461+
);
462+
463+
/** The project root a Claude `project` / `local` registration belongs to: the cwd the host verbs run in. */
464+
export const publicHostProjectRoot = (
465+
host: Exclude<InstallHost, 'cursor'>,
466+
scope: InstallScope,
467+
identity: PluginIdentity,
468+
): string | undefined => host === 'claude' && scope !== 'user' ? identity.bundleRoot : undefined;
469+
470+
/** `<host> plugin marketplace list --json`: whether a marketplace of this name is configured; `unknown` when unusable. */
471+
export const readPublicHostMarketplaceState = async (
472+
runner: InstallCommandRunner,
473+
identity: PluginIdentity,
474+
host: Exclude<InstallHost, 'cursor'>,
475+
marketplace: string,
476+
): Promise<'absent' | 'present' | 'unknown'> => {
477+
let stdout: string;
478+
try {
479+
const result = await runner.run(host, ['plugin', 'marketplace', 'list', '--json'], { cwd: identity.bundleRoot });
480+
if (result.code !== 0) return 'unknown';
481+
stdout = result.stdout;
482+
} catch {
483+
return 'unknown';
484+
}
485+
let document: unknown;
486+
try {
487+
document = JSON.parse(stdout) as unknown;
488+
} catch {
489+
return 'unknown';
490+
}
491+
const rows = host === 'claude'
492+
? document
493+
: typeof document === 'object' && document !== null ? (document as { readonly marketplaces?: unknown }).marketplaces : undefined;
494+
if (!Array.isArray(rows)) return 'unknown';
495+
return rows.some((row) => typeof row === 'object' && row !== null && (row as { readonly name?: unknown }).name === marketplace)
496+
? 'present'
497+
: 'absent';
498+
};
451499

452500
/** Where the store receipt for a Cursor marketplace-mode install lives. */
453501
export const cursorMarketplaceReceiptPath = (cursorRoot: string, plugin: string): string =>
@@ -479,7 +527,8 @@ const installPublicCli = async (
479527
host,
480528
);
481529
}
482-
const receiptPath = publicHostReceiptPath(host, identity.plugin, scope, environment, home);
530+
const projectRoot = publicHostProjectRoot(host, scope, identity);
531+
const receiptPath = publicHostReceiptPath(host, identity.plugin, scope, environment, home, projectRoot);
483532
const previousReceipt = await readInstallReceiptFile(receiptPath);
484533
const base = {
485534
bundleRoot: identity.bundleRoot,
@@ -500,14 +549,25 @@ const installPublicCli = async (
500549
// replacement, and refreshed when an identical copy is found without one (pre-#101 installs). It owns
501550
// no files — the host owns its cache copy — so only the content hash rides along, never an inventory.
502551
const storeInventory: TreeInventory = Object.freeze({ files: Object.freeze([]), hash: artifact.hash });
503-
const receiptIdentity: InstallReceiptIdentity = {
504-
host,
505-
...(previousReceipt === undefined ? {} : { installedAt: previousReceipt.installedAt }),
506-
mode: 'host-cli',
507-
plugin: identity.plugin,
508-
registrations: publicHostRegistrations(host, id, marketplace, scope),
509-
scope,
510-
version: identity.version,
552+
// The marketplace registration is recorded — and therefore reversed by `uninstall` — only when this
553+
// install (or the receipted install it replaces) created it. A marketplace that was already configured
554+
// belongs to whoever configured it; when `plugin marketplace list --json` cannot say, the registration
555+
// is not claimed either (fail-closed: `uninstall` then retains it and says why).
556+
const receiptIdentity = async (): Promise<InstallReceiptIdentity> => {
557+
const ownsMarketplace = previousReceipt !== undefined
558+
? previousReceipt.registrations.some((registration) => registration.kind === `${host}-marketplace`)
559+
: await readPublicHostMarketplaceState(runner, identity, host, marketplace) === 'absent';
560+
return {
561+
host,
562+
...(previousReceipt === undefined ? {} : { installedAt: previousReceipt.installedAt }),
563+
mode: 'host-cli',
564+
plugin: identity.plugin,
565+
...(projectRoot === undefined ? {} : { projectRoot }),
566+
registrations: publicHostRegistrations(host, id, marketplace, scope)
567+
.filter((registration) => ownsMarketplace || registration.kind !== `${host}-marketplace`),
568+
scope,
569+
version: identity.version,
570+
};
511571
};
512572
if (entry !== undefined) {
513573
destination = join(dirname(entry.installPath), identity.version);
@@ -529,7 +589,7 @@ const installPublicCli = async (
529589
const sameVersion = entry.version === identity.version;
530590
if (installed !== undefined && sameVersion && installed.hash === artifact.hash) {
531591
if (previousReceipt === undefined || previousReceipt.contentHash !== artifact.hash) {
532-
await writeStoredInstallReceipt(receiptPath, createInstallReceipt({ ...receiptIdentity, inventory: storeInventory }));
592+
await writeStoredInstallReceipt(receiptPath, createInstallReceipt({ ...(await receiptIdentity()), inventory: storeInventory }));
533593
}
534594
return { ...base, destination: entry.installPath, state: 'already-installed' };
535595
}
@@ -550,12 +610,16 @@ const installPublicCli = async (
550610
}
551611
const contentDrift = installed !== undefined && sameVersion && installed.hash !== artifact.hash;
552612
if (options.replace === true || contentDrift) {
553-
await runHostCommand(runner, identity, host, publicHostUninstallArguments(host, id, scope), 'removal');
554613
replaced = true;
555614
// The receipt remembers what the superseded copy hashed when the copy itself cannot be read.
556615
previousContentHash = installed?.hash ?? previousReceipt?.contentHash;
557616
}
558617
}
618+
// Decided before any host verb runs, so the marketplace ownership check sees the pre-install state.
619+
const recorded = await receiptIdentity();
620+
if (replaced) {
621+
await runHostCommand(runner, identity, host, publicHostUninstallArguments(host, id, scope), 'removal');
622+
}
559623
await runHostCommand(runner, identity, host, [
560624
'plugin',
561625
'marketplace',
@@ -566,7 +630,7 @@ const installPublicCli = async (
566630
? ['plugin', 'install', id, '--scope', scope]
567631
: ['plugin', 'add', id]);
568632
await writeStoredInstallReceipt(receiptPath, createInstallReceipt({
569-
...receiptIdentity,
633+
...recorded,
570634
inventory: storeInventory,
571635
updatedAt: new Date().toISOString(),
572636
}));

0 commit comments

Comments
 (0)