From 0907d95e64fbd554986cf795e191a40419237eef Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Thu, 3 Sep 2026 09:13:19 +0000 Subject: [PATCH 01/22] feat(install): same-version replace path for every emitted installer (#400) `agent-bundle install `, the package-relative installer bin, and the emitted standalone `install.mjs` accept `--replace` (alias `--force`) and automatically replace an agent-bundle install of the same plugin whose version matches but whose content hash differs, so rebuilding without a version bump no longer needs a manual uninstall + rm -rf. Cursor copies now carry an install receipt (`.agent-bundle-install.json`: plugin, version, host, content hash, owned files). Replacement is in place and touches owned files only, so `state/` and other unowned entries survive. Claude replacement runs `plugin uninstall --keep-data` before reinstalling (its `plugin update` is version-gated); Codex runs `plugin remove` before `add`. Both hosts are located through their own `plugin list --json` inventory; an unusable inventory fails `--replace` closed. Foreign directories are still refused (AB7005), now with an installed-versus-artifact content-hash comparison in the message. `agent-bundle doctor --from` reports the installed copy per host as current, stale (same version, different content), version-mismatch, foreign (new AB7321), or not-installed, with both hashes. Emitted INSTALL.md documents the reinstall recipe per host. The real-host install proofs now perform a same-version rebuild round trip for Claude, Codex, Cursor, and portable. --- .changeset/400-install-replace.md | 5 + docs/diagnostics.md | 55 ++- docs/framework-mode.md | 14 +- packages/agent-bundle/README.md | 54 ++- packages/agent-bundle/src/cli.ts | 64 ++- packages/agent-bundle/src/install-entry.ts | 38 +- packages/agent-bundle/src/install/doctor.ts | 392 +++++++++++++++--- packages/agent-bundle/src/install/install.ts | 369 ++++++++++++----- packages/agent-bundle/src/install/receipt.ts | 379 +++++++++++++++++ packages/agent-bundle/src/install/surface.ts | 287 +++++++++++-- packages/agent-bundle/tests/cli.test.ts | 1 + packages/agent-bundle/tests/doctor.test.ts | 236 ++++++++++- .../tests/host-install-proof.test.ts | 8 +- .../tests/install-surface.test.ts | 157 +++++++ packages/agent-bundle/tests/install.test.ts | 307 ++++++++++++-- .../tests/installer-entry.test.ts | 33 +- .../tests/packed-host-install-proof.test.ts | 6 +- .../tests/support/host-install.ts | 107 ++++- 18 files changed, 2274 insertions(+), 238 deletions(-) create mode 100644 .changeset/400-install-replace.md create mode 100644 packages/agent-bundle/src/install/receipt.ts diff --git a/.changeset/400-install-replace.md b/.changeset/400-install-replace.md new file mode 100644 index 000000000..65e0313d7 --- /dev/null +++ b/.changeset/400-install-replace.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": minor +--- + +Add a same-version replace path to every emitted installer (#400). `agent-bundle install `, the package-relative installer bin, and the emitted standalone `install.mjs` accept `--replace` (alias `--force`) and automatically replace an agent-bundle install of the same plugin whose version matches but whose content hash differs, so rebuilding without a version bump no longer needs a manual uninstall + `rm -rf`. Cursor copies now carry an install receipt (`.agent-bundle-install.json`: plugin, version, content hash, owned files); replacement is in place and touches owned files only, leaving `state/` and other unowned entries intact. Claude replacement runs `plugin uninstall --keep-data` before reinstalling because `plugin update` is version-gated; Codex runs `plugin remove` before `add`. Foreign directories are still refused, now with an installed-versus-artifact content-hash comparison in the message. `agent-bundle doctor --from` reports the installed copy per host as `current`, `stale (same version, different content)`, `version-mismatch`, `foreign` (new `AB7321`), or `not-installed`, and emitted `INSTALL.md` documents the reinstall recipe per host. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index d808bc684..0aaa28f2e 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -28,11 +28,11 @@ gate a build, a validation, or a dev rebuild. | `AB490x`/`AB492x` | Conventional host components (#100 stage 2): rules `src/rules/*.mdc` (`AB4900`–`AB4906`) and commands `src/commands/*.md` (`AB4920`–`AB4926`); see below. | | `AB5000` | General CLI and adapter failures. | | `AB60xx` | Built-artifact validation, including schema documents and referenced files (`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). | -| `AB700x` | Host installation: bundle identity, host availability, scope, command failure, and collision checks. | +| `AB700x` | Host installation: bundle identity, host availability, scope, command failure, and collision checks (`AB7005`: version collision, pre-receipt content collision, or foreign install; see below). | | `AB7010`–`AB7013` | npm prepack inventory, artifact freshness, package bin targets, and release-version agreement. | | `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. | -| `AB7300`–`AB7320` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, runtime endpoint health and identity, durable-state inventory, and static bytes-at-rest validation. | +| `AB7300`–`AB7321` | 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, and foreign-install detection (`AB7321`; see below). | | `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. | @@ -672,6 +672,57 @@ host CLI, repair a bundle, or perform a live protocol exchange. | `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 and detail. | Rebuild that host bundle from valid source bytes, then rerun Doctor. | | `AB7320` | error / info | Error when a `.cursor-plugin/plugin.json` install violates Cursor's pinned document schemas or token-location rules, when a root `plugin.json` install that declares an Agent Plugins `$schema` violates the pinned Agent Plugins 1.0.0 contract (`AB6035`–`AB6037`, retained in the message), or when any local plugin contains a symlink that escapes `~/.cursor/plugins/local`; the inventory entry is reported as `corrupt`. Info naming the contract applied to an Agent Plugins install, or stating that a `.claude-plugin/plugin.json` (or schema-less root `plugin.json`) install has no Cursor-side pinned static document contract; loader-recognized entries remain `installed`. | Reinstall an invalid Cursor plugin, rebuild an invalid portable bundle, or repair an escaping symlink. For other manifest flavors, use that ecosystem's validator when static document proof is required. | +## Install replacement and Doctor install comparison (`AB7005`, `AB7307`–`AB7309`, `AB7321`) + +`agent-bundle install ` and the emitted standalone `install.mjs` share one +replace policy, and `agent-bundle doctor --from ` reports the same +verdict read-only. Every Cursor copy an agent-bundle installer places carries an +install receipt, `.agent-bundle-install.json`, beside the plugin manifest: + +```json +{ + "contentHash": "", + "files": [".cursor-plugin/plugin.json", "INSTALL.md", "install.mjs", "..."], + "format": "agent-bundle-install-receipt/1", + "host": "cursor", + "installedAt": "2026-09-03T08:00:00.000Z", + "plugin": "", + "version": "" +} +``` + +The receipt never participates in the content hash. 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 +`plugin list --json` inventory; the host owns those copies, so replacement runs +`claude plugin uninstall --keep-data` + `install` or `codex plugin remove` + +`add`. + +| Installed copy | `install` | `install --replace` (alias `--force`) | Doctor | +| --- | --- | --- | --- | +| Identical content | `already-installed` no-op | `already-installed` no-op | `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 | replaced and adopted (receipt written) | `stale` — `AB7308` warning, recovery names `--replace` | +| Foreign directory | `AB7005` foreign install | `AB7005` foreign install | `foreign` — `AB7321` warning | +| Nothing installed | installed | installed | `not-installed` — `AB7307` info | + +Every `AB7005`, `AB7308`, `AB7309`, and `AB7321` message carries the comparison +`installed @ content vs artifact @ content + (same version, different content | different version | same content)`. +Cursor replacement is in place and touches owned files only: stale owned files +are removed and their empty directories pruned, staged files are renamed over +their predecessors, and the receipt lands last. Entries the installer does not +own — notably workspace-durable `state/` stores — are never removed or +rewritten. + +| 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. | + ## Live development into hosts (`AB7200`–`AB7202`, `AB7210`–`AB7211`, `AB8024`–`AB8025`) `agent-bundle dev` keeps a host's one stdio MCP process connected while it diff --git a/docs/framework-mode.md b/docs/framework-mode.md index 2c855b7ea..bbdc2e084 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -363,6 +363,14 @@ agent-bundle install cursor --from artifact/cursor Cursor-compatible `cursor`, `portable`, and multi-host `plugin` targets also include a standalone `install.mjs`. Its staged copy is idempotent for identical -content and refuses version or content collisions. It never invokes sudo or -changes PATH. Artifact validation rejects a built-in target whose required -install surface is missing. +content, records an install receipt (`.agent-bundle-install.json`: plugin, +version, content hash, owned files), replaces a same-version stale copy of its +own plugin in place (owned files only; `state/` survives), and accepts +`--replace` (alias `--force`) 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 +[--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 +"Reinstall after a same-version rebuild"). Artifact validation rejects a +built-in target whose required install surface is missing. diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index 265125564..d6172344b 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -112,7 +112,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`, and `--json` supported). | +| `agent-bundle install ` | Install a built bundle into Claude, Codex, or Cursor (`--from`, `--scope`, `--replace`/`--force`, and `--json` supported). Same-version content drift of an agent-bundle-managed install is replaced automatically; identical reruns are a no-op. | +| `agent-bundle doctor` | Read-only host inspection: host probes, installed inventory, and, with `--from`, the installed copy compared against the built artifact by version and content hash (`current`, `stale`, `version-mismatch`, `foreign`, `not-installed`). | | `agent-bundle validate` | Validate project source, or an artifact with `--artifact`. | | `agent-bundle inspect` | Inspect normalized targets and adapter plans from source, with per-target 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 synthesized Rslib/Rsbuild configs (post-`tools`-hatch merge) for every generated output. | @@ -173,11 +174,60 @@ Cursor installation is user-scoped. Claude also accepts `--scope project` and `--scope local`; Codex is user-scoped. A source-free artifact root is accepted by `--from` when it contains the selected host target directory. +### Reinstall after a same-version rebuild + +Rebuilding a plugin without bumping its `version` is the normal local loop, and +every host treats it differently. `agent-bundle install` and the emitted +`install.mjs` share one replace policy: + +- **Receipt.** Every Cursor copy an agent-bundle installer places carries + `.agent-bundle-install.json` beside the plugin manifest: the plugin name, + version, host, the sha256 content hash of the artifact tree, when it was + installed, and the exact list of files the installer owns. The receipt never + participates in the content hash, so an installed copy hashes like the + artifact it came from. +- **No-op.** Re-running install on an identical artifact reports + `already-installed` and changes nothing, even with `--replace`. +- **Automatic same-version replace.** When the installed copy is an + agent-bundle install of the same plugin at the same version but its content + 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. Claude replacement runs + `claude plugin uninstall @ --scope --keep-data` + before `marketplace add` + `install`, because Claude's `plugin update` is + version-gated and a plain reinstall reports "already installed" while the + cache stays stale. Codex replacement runs `codex plugin remove` before + `marketplace add` + `add`, so files a rebuild removed do not linger. +- **`--replace` (alias `--force`).** 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). Adoption writes the receipt, so + later same-version rebuilds replace automatically. +- **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 + artifact @ content (same version, different content)`), + even with `--replace`. Remove it manually. + +For Claude and Codex the installed copy is located through the host's own +`plugin list --json` inventory (Claude reports the cache path; Codex confirms +the install and its pinned `~/.codex/plugins/cache///` +layout supplies the path). When that inventory is unusable, a plain install +proceeds as before and `--replace` fails closed rather than guessing. + +`agent-bundle doctor --from ` reports the same comparison per host +without changing anything: the installed version and content hash versus the +built artifact, summarised as `current`, `stale (same version, different +content)` (`AB7308`), `version mismatch` (`AB7309`), `foreign install` +(`AB7321`), `not installed` (`AB7307`), or `unknown` when the host inventory +could not be read. + When package outputs ship one of those host packs, the build also emits a package-relative installer bin. It uses the plugin name when no configured bin claims it and `-install` otherwise. Map that name to the generated `dist/bin/*.js` file in `package.json`; consumers run -` install [--scope ] [--json]`. The executable locates the +` install [--scope ] [--replace|--force] [--json]`. The executable locates the artifact directory beside the installed package, so it works from `node_modules` regardless of the current directory. No npm lifecycle performs an installation. diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts index e0b5030f5..e74738f1f 100644 --- a/packages/agent-bundle/src/cli.ts +++ b/packages/agent-bundle/src/cli.ts @@ -29,6 +29,7 @@ import type { import type { DoctorDurableStateReport, DoctorHost, + DoctorInstallComparison, DoctorReport, runDoctor, } from './install/doctor.ts'; @@ -82,8 +83,10 @@ interface BuildCommandOptions extends SourceCommandOptions { } interface InstallCommandOptions { + readonly force?: boolean; readonly from: string; readonly json?: boolean; + readonly replace?: boolean; readonly scope: string; } @@ -274,14 +277,61 @@ const writeHumanPrepack = (output: Output, result: Awaited { + switch (state) { + case 'already-installed': + return 'Already installed'; + case 'installed': + return 'Installed'; + case 'replaced': + return 'Replaced'; + default: { + const exhaustive: never = state; + throw new TypeError(`Unknown install state ${String(exhaustive)}.`); + } + } +}; + +const shortContentHash = (hash: string): string => hash.slice(0, 12); + const writeHumanInstall = (output: Output, result: InstallResult): void => { const destination = result.destination ?? result.bundleRoot; + const content = result.previousContentHash !== undefined && result.contentHash !== undefined + ? ` (content ${shortContentHash(result.previousContentHash)} -> ${shortContentHash(result.contentHash)})` + : result.contentHash === undefined + ? '' + : ` (content ${shortContentHash(result.contentHash)})`; output.write( - `${result.state === 'already-installed' ? 'Already installed' : 'Installed'} ` + - `${result.plugin}@${result.version} for ${result.host} at ${destination}\n`, + `${installStateLabel(result.state)} ${result.plugin}@${result.version} for ${result.host} at ${destination}${content}\n`, ); }; +const describeInstallComparison = (comparison: DoctorInstallComparison): string => { + const installed = comparison.installedContentHash === undefined + ? '' + : `; installed ${comparison.installedVersion ?? 'unknown version'} ` + + `content ${shortContentHash(comparison.installedContentHash)}, ` + + `artifact content ${shortContentHash(comparison.artifactContentHash)}`; + switch (comparison.status) { + case 'current': + return `current${installed}`; + case 'stale': + return `stale (same version, different content)${installed}`; + case 'version-mismatch': + return `version mismatch${installed}`; + case 'foreign': + return `foreign install${installed}`; + case 'not-installed': + return 'not installed'; + case 'unknown': + return 'unknown (host inventory unavailable)'; + default: { + const exhaustive: never = comparison.status; + throw new TypeError(`Unknown install comparison ${String(exhaustive)}.`); + } + } +}; + const formatByteSize = (bytes: number): string => { if (bytes < 1024) return `${bytes} B`; const kibibytes = bytes / 1024; @@ -302,6 +352,9 @@ const writeHumanDoctor = (output: Output, result: DoctorReport): void => { ? '' : ` ${host.bundle.name}${host.bundle.version === undefined ? '' : `@${host.bundle.version}`}`; output.write(` bundle:${identity} ${host.bundle.state}\n`); + if (host.bundle.comparison !== undefined) { + output.write(` installed copy: ${describeInstallComparison(host.bundle.comparison)}\n`); + } } const reports = [ ...host.inventory.findings.map((finding) => finding.durableState), @@ -571,6 +624,12 @@ export const runCli = async ( .argument('', 'Destination host: claude, codex, or cursor', installHost) .option('--from ', 'Target bundle directory or artifact root', process.cwd()) .option('--scope ', 'Host install scope', installScope, 'user') + .option( + '--replace', + 'Replace an existing agent-bundle install of this plugin even when its version differs; ' + + 'same-version content drift is replaced automatically and foreign installs are always refused', + ) + .option('--force', 'Alias for --replace') .option('--json', 'Write one machine-readable JSON document'); installCommand.action(async ( host: InstallHost, @@ -580,6 +639,7 @@ export const runCli = async ( const result = await install({ from: options.from, host, + replace: options.replace === true || options.force === true, scope: installScope(options.scope), }); if (options.json === true) writeMachine(stdout, result); diff --git a/packages/agent-bundle/src/install-entry.ts b/packages/agent-bundle/src/install-entry.ts index 8749239a3..8f854ea32 100644 --- a/packages/agent-bundle/src/install-entry.ts +++ b/packages/agent-bundle/src/install-entry.ts @@ -17,10 +17,14 @@ export interface GeneratedInstallProcessOptions { } const usage = (options: GeneratedInstallProcessOptions): string => [ - `Usage: ${options.name} install [--scope ] [--json]`, + `Usage: ${options.name} install [--scope ] [--replace|--force] [--json]`, '', `Built hosts: ${options.hosts.join(', ')}`, '', + '--replace (alias --force) replaces an existing agent-bundle install of this plugin even when', + 'its version differs. Same-version content drift is replaced automatically; foreign installs', + 'are always refused.', + '', ].join('\n'); const diagnosticsFor = (error: unknown): readonly Diagnostic[] => @@ -32,11 +36,30 @@ const diagnosticsFor = (error: unknown): readonly Diagnostic[] => severity: 'error' as const, })]); +const stateLabel = (state: InstallResult['state']): string => { + switch (state) { + case 'already-installed': + return 'Already installed'; + case 'installed': + return 'Installed'; + case 'replaced': + return 'Replaced'; + default: { + const exhaustive: never = state; + throw new TypeError(`Unknown install state ${String(exhaustive)}.`); + } + } +}; + const writeHuman = (result: InstallResult): void => { const destination = result.destination ?? result.bundleRoot; + const content = result.previousContentHash !== undefined && result.contentHash !== undefined + ? ` (content ${result.previousContentHash.slice(0, 12)} -> ${result.contentHash.slice(0, 12)})` + : result.contentHash === undefined + ? '' + : ` (content ${result.contentHash.slice(0, 12)})`; process.stdout.write( - `${result.state === 'already-installed' ? 'Already installed' : 'Installed'} ` + - `${result.plugin}@${result.version} for ${result.host} at ${destination}\n`, + `${stateLabel(result.state)} ${result.plugin}@${result.version} for ${result.host} at ${destination}${content}\n`, ); }; @@ -49,6 +72,7 @@ const isScope = (value: string): value is InstallScope => interface ParsedInstallArguments { readonly host: InstallHost; readonly json: boolean; + readonly replace: boolean; readonly scope: InstallScope; } @@ -66,6 +90,7 @@ const parseArguments = ( ); } let json = false; + let replace = false; let scope: InstallScope = 'user'; for (let index = 2; index < argv.length; index += 1) { const argument = argv[index]; @@ -73,6 +98,10 @@ const parseArguments = ( json = true; continue; } + if (argument === '--replace' || argument === '--force') { + replace = true; + continue; + } if (argument === '--scope') { const value = argv[index + 1]; if (value === undefined || !isScope(value)) { @@ -84,7 +113,7 @@ const parseArguments = ( } throw new TypeError(`Unknown installer argument ${JSON.stringify(argument)}.`); } - return Object.freeze({ host: candidate, json, scope }); + return Object.freeze({ host: candidate, json, replace, scope }); }; export const runGeneratedInstallProcess = async ( @@ -111,6 +140,7 @@ export const runGeneratedInstallProcess = async ( const result = await installBundle({ from: artifactRoot, host: parsed.host, + replace: parsed.replace, scope: parsed.scope, }); if (parsed.json) process.stdout.write(`${stableJson(result)}\n`); diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index 9643d71e4..503595e2c 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -24,7 +24,20 @@ import type { } from '../host-contracts/process.ts'; import { runBoundedChildProcess } from '../host-contracts/process.ts'; import { requestEventRuntimeStatus } from '../events/ipc.ts'; -import { treeHash, type InstallHost } from './install.ts'; +import { + parsePublicHostInventory, + publicHostCacheRoot, + type InstallHost, + type PublicHostInventory, +} from './install.ts'; +import { + compareInstalledTree, + describeContentComparison, + treeInventory, + type InstalledTreeComparison, + type InstalledTreeOwnership, + type TreeInventory, +} from './receipt.ts'; export type DoctorHost = InstallHost; export type DoctorHostProbeStatus = 'available' | 'failed' | 'unavailable'; @@ -54,6 +67,8 @@ export type DoctorCommandRunner = ( export interface DoctorOptions { readonly commandRunner?: DoctorCommandRunner; readonly endpointDirectory?: string; + /** Process environment consulted for host cache roots (`CODEX_HOME`); defaults to `process.env`. */ + readonly environment?: Readonly; readonly from?: string; readonly home?: string; readonly hosts?: readonly DoctorHost[]; @@ -112,9 +127,35 @@ export interface DoctorInventory { readonly status: DoctorInventoryStatus; } +export type DoctorInstallComparisonStatus = + | 'current' + | 'foreign' + | 'not-installed' + | 'stale' + | 'unknown' + | 'version-mismatch'; + +/** + * Installed copy versus the built artifact: `current` (same content), + * `stale` (same version, different content), `version-mismatch`, `foreign` + * (a directory at the install path that is not an agent-bundle install of + * this plugin), `not-installed`, or `unknown` when the host inventory could + * not be read. + */ +export interface DoctorInstallComparison { + readonly artifactContentHash: string; + 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. */ + readonly ownership?: InstalledTreeOwnership | 'host'; + readonly status: DoctorInstallComparisonStatus; +} + export interface DoctorHostReport { readonly bundle?: DoctorFinding & { readonly bundleRoot?: string; + readonly comparison?: DoctorInstallComparison; readonly marketplace?: string; }; readonly diagnostics: readonly Diagnostic[]; @@ -752,6 +793,165 @@ const malformedBundle = ( }; }; +const installComparison = ( + comparison: InstalledTreeComparison, + installedPath: string, +): DoctorInstallComparison => Object.freeze({ + artifactContentHash: comparison.artifactContentHash, + installedContentHash: comparison.installedContentHash, + installedPath, + ...(comparison.installedVersion === undefined ? {} : { installedVersion: comparison.installedVersion }), + ownership: comparison.ownership, + status: comparison.status, +}); + +/** + * The host's own installed-plugin inventory (`plugin list --json`), read + * through the same parser `agent-bundle install` uses before replacing. + * Doctor looks across scopes, so a Claude row at any scope counts. + */ +const readPublicHostInventory = async ( + host: Exclude, + identity: PluginIdentity, + run: DoctorCommandRunner, + environment: Readonly, + home: string, +): Promise => { + let result: DoctorCommandResult; + try { + result = await run(Object.freeze({ + args: Object.freeze(['plugin', 'list', '--json']), + cwd: identity.bundleRoot, + executable: host, + })); + } catch (error) { + return { detail: error instanceof Error ? error.message : String(error), status: 'unavailable' }; + } + if (result.exitCode !== 0 || result.termination !== undefined) { + return { + detail: result.termination ?? (result.stderr.trim() || `exit code ${result.exitCode ?? 'unknown'}`), + status: 'unavailable', + }; + } + return parsePublicHostInventory(host, result.stdout, { + cacheRoot: publicHostCacheRoot(host, environment, home), + marketplace: identity.marketplace ?? '', + plugin: identity.name, + }); +}; + +const publicHostReplaceRecipe = (host: Exclude): string => host === 'claude' + ? 'Rerun `agent-bundle install claude --from `; same-version content drift is replaced through ' + + '`claude plugin uninstall --keep-data` + `claude plugin install` because Claude\'s `plugin update` is version-gated.' + : 'Rerun `agent-bundle install codex --from `; same-version content drift is replaced through ' + + '`codex plugin remove` + `codex plugin add`.'; + +/** + * Compares the copy a public host CLI caches for this plugin against the + * built artifact. Unusable inventories degrade to `unknown` (Doctor never + * guesses a cache path without the host confirming the install). + */ +const publicHostInstallComparison = async ( + host: Exclude, + identity: PluginIdentity, + artifact: TreeInventory, + inventory: PublicHostInventory, +): Promise<{ readonly comparison: DoctorInstallComparison; readonly diagnostics: readonly Diagnostic[] }> => { + if (inventory.status === 'unavailable') { + return { + comparison: Object.freeze({ artifactContentHash: artifact.hash, status: 'unknown' }), + diagnostics: Object.freeze([]), + }; + } + const entry = inventory.entry; + if (entry === undefined) { + return { + comparison: Object.freeze({ artifactContentHash: artifact.hash, status: 'not-installed' }), + diagnostics: freezeDiagnostics([diagnostic( + 'AB7307', + `${identity.name}@${identity.version} is not installed for ${host}.`, + `Run \`agent-bundle install ${host} --from \`.`, + 'info', + host, + )]), + }; + } + let installed: TreeInventory; + try { + installed = await treeInventory(entry.installPath); + } catch (error) { + return { + comparison: Object.freeze({ + artifactContentHash: artifact.hash, + installedPath: entry.installPath, + ...(entry.version === undefined ? {} : { installedVersion: entry.version }), + ownership: 'host', + status: 'unknown', + }), + diagnostics: freezeDiagnostics([diagnostic( + 'AB7310', + `${host} installed copy at ${JSON.stringify(entry.installPath)} could not be compared: ` + + `${error instanceof Error ? error.message : String(error)}`, + `Reinstall the ${host} plugin with \`agent-bundle install ${host} --from --replace\`.`, + 'error', + host, + )]), + }; + } + const status: 'current' | 'stale' | 'version-mismatch' = + installed.hash === artifact.hash && entry.version === identity.version + ? 'current' + : entry.version !== undefined && entry.version !== identity.version + ? 'version-mismatch' + : 'stale'; + const comparison: DoctorInstallComparison = Object.freeze({ + artifactContentHash: artifact.hash, + installedContentHash: installed.hash, + installedPath: entry.installPath, + ...(entry.version === undefined ? {} : { installedVersion: entry.version }), + ownership: 'host', + status, + }); + const detail = describeContentComparison(identity.name, identity.version, { + artifactContentHash: artifact.hash, + installedContentHash: installed.hash, + installedName: identity.name, + ...(entry.version === undefined ? {} : { installedVersion: entry.version }), + status, + }); + switch (status) { + case 'current': + return { comparison, diagnostics: Object.freeze([]) }; + case 'stale': + return { + comparison, + diagnostics: freezeDiagnostics([diagnostic( + 'AB7308', + `${host} plugin ${identity.name}@${identity.version} at ${entry.installPath} is stale ` + + `(same version, different content): ${detail}.`, + publicHostReplaceRecipe(host), + 'warning', + host, + )]), + }; + case 'version-mismatch': + return { + comparison, + diagnostics: freezeDiagnostics([diagnostic( + 'AB7309', + `${host} version collision at ${entry.installPath}: ${detail}.`, + `Rerun \`agent-bundle install ${host} --from --replace\` to replace the installed version.`, + 'warning', + host, + )]), + }; + default: { + const exhaustive: never = status; + throw new TypeError(`Unknown install comparison ${String(exhaustive)}.`); + } + } +}; + const cursorBundle = async ( identity: PluginIdentity, home: string, @@ -764,7 +964,7 @@ const cursorBundle = async ( version: identity.version, } as const; try { - await treeHash(identity.bundleRoot); + const artifact = await treeInventory(identity.bundleRoot); if (!await exists(destination)) { return { diagnostics: freezeDiagnostics([diagnostic( @@ -774,7 +974,11 @@ const cursorBundle = async ( 'info', 'cursor', )]), - finding: Object.freeze({ ...base, state: 'missing' }), + finding: Object.freeze({ + ...base, + comparison: Object.freeze({ artifactContentHash: artifact.hash, status: 'not-installed' as const }), + state: 'missing', + }), }; } const installed = await readInstalledManifest(destination); @@ -787,38 +991,77 @@ const cursorBundle = async ( 'error', 'cursor', )]), - finding: Object.freeze({ ...base, state: 'corrupt' }), - }; - } - if (installed.version !== undefined && installed.version !== identity.version) { - return { - diagnostics: freezeDiagnostics([diagnostic( - 'AB7309', - `Cursor version collision at ${destination}: found ${installed.version}, expected ${identity.version}.`, - 'Choose the intended version, remove the conflicting copy manually, and reinstall.', - 'warning', - 'cursor', - )]), - finding: Object.freeze({ ...base, state: 'conflicted' }), + finding: Object.freeze({ + ...base, + comparison: Object.freeze({ artifactContentHash: artifact.hash, status: 'unknown' as const }), + state: 'corrupt', + }), }; } - const [sourceHash, installedHash] = await Promise.all([ - treeHash(identity.bundleRoot), - treeHash(destination), - ]); - if (sourceHash === installedHash) { - return { diagnostics: Object.freeze([]), finding: Object.freeze({ ...base, state: 'installed' }) }; + const comparison = await compareInstalledTree({ + artifact, + destination, + installedManifest: { + name: installed.name, + ...(installed.version === undefined ? {} : { version: installed.version }), + }, + plugin: identity.name, + version: identity.version, + }); + const detail = describeContentComparison(identity.name, identity.version, comparison); + const withComparison = (state: DoctorFindingState): DoctorHostReport['bundle'] => Object.freeze({ + ...base, + comparison: installComparison(comparison, destination), + state, + }); + switch (comparison.status) { + case 'current': + return { diagnostics: Object.freeze([]), finding: withComparison('installed') }; + case 'version-mismatch': + return { + diagnostics: freezeDiagnostics([diagnostic( + 'AB7309', + `Cursor version collision at ${destination}: ${detail}.`, + 'Choose the intended version; `agent-bundle install cursor --replace` (or `install.mjs --replace`) ' + + 'replaces this agent-bundle install, or remove the conflicting copy manually.', + 'warning', + 'cursor', + )]), + finding: withComparison('conflicted'), + }; + case 'foreign': + return { + diagnostics: freezeDiagnostics([diagnostic( + 'AB7321', + `Cursor destination ${destination} is a foreign install: ${detail}; ` + + `it is not an agent-bundle install of ${identity.name}.`, + 'Remove the foreign directory manually before installing; `--replace` refuses foreign installs.', + 'warning', + 'cursor', + )]), + finding: withComparison('conflicted'), + }; + case 'stale': + return { + diagnostics: freezeDiagnostics([diagnostic( + 'AB7308', + `Cursor plugin ${identity.name}@${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.', + 'warning', + 'cursor', + )]), + finding: withComparison('drifted'), + }; + default: { + const exhaustive: never = comparison.status; + throw new TypeError(`Unknown install comparison ${String(exhaustive)}.`); + } } - return { - diagnostics: freezeDiagnostics([diagnostic( - 'AB7308', - `Cursor plugin ${identity.name}@${identity.version} differs from the current bundle.`, - 'Reinstall the Cursor plugin from the current bundle.', - 'warning', - 'cursor', - )]), - finding: Object.freeze({ ...base, state: 'drifted' }), - }; } catch (error) { return { diagnostics: freezeDiagnostics([diagnostic( @@ -833,7 +1076,7 @@ const cursorBundle = async ( } }; -const claudeBundle = async ( +const claudeRegistration = async ( identity: PluginIdentity, probe: DoctorHostProbe, run: DoctorCommandRunner, @@ -928,24 +1171,76 @@ const claudeBundle = async ( return { diagnostics: Object.freeze([]), finding: Object.freeze({ ...base, state: 'registered' }) }; }; -const codexBundle = ( +interface PublicHostContext { + readonly environment: Readonly; + readonly home: string; + readonly run: DoctorCommandRunner; +} + +/** Claude: inline registration proof plus the installed cache copy compared against the artifact. */ +const claudeBundle = async ( + identity: PluginIdentity, + probe: DoctorHostProbe, + context: PublicHostContext, +): Promise<{ readonly diagnostics: readonly Diagnostic[]; readonly finding: DoctorHostReport['bundle'] }> => { + const registration = await claudeRegistration(identity, probe, context.run); + if (probe.status !== 'available' || registration.finding === undefined) return registration; + const artifact = await treeInventory(identity.bundleRoot); + const inventory = await readPublicHostInventory('claude', identity, context.run, context.environment, context.home); + const compared = await publicHostInstallComparison('claude', identity, artifact, inventory); + return { + diagnostics: freezeDiagnostics([...registration.diagnostics, ...compared.diagnostics]), + finding: Object.freeze({ ...registration.finding, comparison: compared.comparison }), + }; +}; + +/** + * Codex: `codex plugin list --json` (pinned at 0.147.0 and by the real-host + * install proof) names installed plugins, so the installed cache copy is + * compared against the artifact; an unusable inventory stays `unknown`. + */ +const codexBundle = async ( identity: PluginIdentity, -): { readonly diagnostics: readonly Diagnostic[]; readonly finding: DoctorHostReport['bundle'] } => ({ - diagnostics: freezeDiagnostics([diagnostic( - 'AB7313', - 'Codex bundle registration is unknown because no read-only inventory verb is pinned.', - 'Use Codex-owned commands to inspect registration; stage 1 intentionally does not guess.', - 'info', - 'codex', - )]), - finding: Object.freeze({ + probe: DoctorHostProbe, + context: PublicHostContext, +): Promise<{ readonly diagnostics: readonly Diagnostic[]; readonly finding: DoctorHostReport['bundle'] }> => { + const base = { bundleRoot: identity.bundleRoot, marketplace: identity.marketplace, name: identity.name, - state: 'unknown', version: identity.version, - }), -}); + } as const; + if (probe.status !== 'available') { + return { diagnostics: Object.freeze([]), finding: Object.freeze({ ...base, state: 'skipped' }) }; + } + const artifact = await treeInventory(identity.bundleRoot); + const inventory = await readPublicHostInventory('codex', identity, context.run, context.environment, context.home); + if (inventory.status === 'unavailable') { + return { + diagnostics: freezeDiagnostics([diagnostic( + 'AB7313', + `Codex bundle registration is unknown because \`codex plugin list --json\` was unusable: ${inventory.detail}.`, + 'Use Codex-owned commands to inspect registration; Doctor does not guess a cache path.', + 'info', + 'codex', + )]), + finding: Object.freeze({ + ...base, + comparison: Object.freeze({ artifactContentHash: artifact.hash, status: 'unknown' as const }), + state: 'unknown', + }), + }; + } + const compared = await publicHostInstallComparison('codex', identity, artifact, inventory); + return { + diagnostics: compared.diagnostics, + finding: Object.freeze({ + ...base, + comparison: compared.comparison, + state: inventory.entry === undefined ? 'missing' : 'installed', + }), + }; +}; type EndpointProbe = 'live' | 'missing' | 'stale'; @@ -1312,11 +1607,12 @@ const doctorHost = async ( identity.bundleRoot, await validateBundleFiles(identity.bundleRoot, host), ); + const context: PublicHostContext = { environment: options.environment ?? process.env, home, run }; const checked = host === 'cursor' ? await cursorBundle(identity, home) : host === 'claude' - ? await claudeBundle(identity, probed.probe, run) - : codexBundle(identity); + ? await claudeBundle(identity, probed.probe, context) + : await codexBundle(identity, probed.probe, context); diagnostics.push(...staticDiagnostics); diagnostics.push(...checked.diagnostics); if (checked.finding === undefined) { diff --git a/packages/agent-bundle/src/install/install.ts b/packages/agent-bundle/src/install/install.ts index 34c540136..768145460 100644 --- a/packages/agent-bundle/src/install/install.ts +++ b/packages/agent-bundle/src/install/install.ts @@ -1,17 +1,7 @@ import { execFile } from 'node:child_process'; -import { createHash } from 'node:crypto'; -import { - cp, - lstat, - mkdir, - mkdtemp, - readFile, - readdir, - rename, - rm, -} from 'node:fs/promises'; +import { lstat, mkdir, readFile, rename, rm } from 'node:fs/promises'; import { homedir } from 'node:os'; -import { basename, join, resolve } from 'node:path'; +import { join, resolve } from 'node:path'; import { Effect, Predicate } from 'effect'; @@ -20,9 +10,20 @@ import { errorMessage, isErrno } from '../core/errors.ts'; import { exists } from '../core/paths.ts'; import { runPromise } from '../effect/boundary.ts'; import { liftPromise } from '../effect/lift.ts'; +import { + compareInstalledTree, + describeContentComparison, + replaceInstalledTree, + stageArtifact, + treeInventory, + type InstalledManifestIdentity, + type InstalledTreeComparison, + type TreeInventory, +} from './receipt.ts'; export type InstallHost = 'claude' | 'codex' | 'cursor'; export type InstallScope = 'local' | 'project' | 'user'; +export type InstallResultState = 'already-installed' | 'installed' | 'replaced'; export interface InstallCommandResult { readonly code: number; @@ -40,19 +41,31 @@ export interface InstallCommandRunner { export interface InstallBundleOptions { readonly commandRunner?: InstallCommandRunner; + /** Process environment consulted for host cache roots (`CODEX_HOME`); defaults to `process.env`. */ + readonly environment?: Readonly; readonly from: string; readonly home?: string; readonly host: InstallHost; + /** + * Replace an existing agent-bundle install of this plugin even when its + * version differs (`--replace` / `--force`). Same-version content drift is + * replaced automatically; foreign directories are always refused. + */ + readonly replace?: boolean; readonly scope?: InstallScope; } export interface InstallResult { readonly bundleRoot: string; + /** sha256 over the artifact tree that was installed or found already installed. */ + readonly contentHash?: string; readonly destination?: string; readonly host: InstallHost; readonly marketplace?: string; readonly plugin: string; - readonly state: 'already-installed' | 'installed'; + /** Content hash of the copy a `replaced` install superseded. */ + readonly previousContentHash?: string; + readonly state: InstallResultState; readonly version: string; } @@ -189,7 +202,8 @@ const runHostCommand = async ( identity: PluginIdentity, host: Exclude, args: readonly string[], -): Promise => { + operation: 'installation' | 'removal' = 'installation', +): Promise => { let result: InstallCommandResult; try { result = await runner.run(host, args, { cwd: identity.bundleRoot }); @@ -201,10 +215,134 @@ const runHostCommand = async ( } if (result.code !== 0) { const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`; - throw failure('AB7004', `${host} plugin installation failed: ${detail}`, host); + throw failure('AB7004', `${host} plugin ${operation} failed: ${detail}`, host); + } + return result; +}; + +/** + * Where a public host CLI caches an installed marketplace plugin; pinned by + * the real-host install proofs and shared with the development install sync. + */ +export const publicHostCacheRoot = ( + host: Exclude, + environment: Readonly, + home: string, +): string => host === 'claude' + ? join(environment['CLAUDE_CONFIG_DIR'] ?? join(home, '.claude'), 'plugins', 'cache') + : join(environment['CODEX_HOME'] ?? join(home, '.codex'), 'plugins', 'cache'); + +export interface PublicHostInstalledEntry { + readonly installPath: string; + readonly version?: string; +} + +/** The host's own answer to "is this plugin installed, and where": usable, or not, never guessed. */ +export type PublicHostInventory = + | { readonly entry?: PublicHostInstalledEntry; readonly status: 'available' } + | { readonly detail: string; readonly status: 'unavailable' }; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** + * Parses ` plugin list --json` for one plugin. Claude rows carry the + * cache path (`installPath`) and scope; Codex rows confirm installation and + * version only, so the pinned cache layout supplies the path. Shared by + * `install` (before replacing) and `doctor` (when comparing), so both read the + * host's inventory identically. + */ +export const parsePublicHostInventory = ( + host: Exclude, + stdout: string, + options: { + readonly cacheRoot: string; + readonly marketplace: string; + readonly plugin: string; + /** Claude only: restrict to rows installed at this scope. */ + readonly scope?: InstallScope; + }, +): PublicHostInventory => { + const id = `${options.plugin}@${options.marketplace}`; + let document: unknown; + try { + document = JSON.parse(stdout) as unknown; + } catch { + return { detail: `${host} plugin list --json did not return JSON`, status: 'unavailable' }; + } + if (host === 'claude') { + if (!Array.isArray(document)) { + return { detail: 'claude plugin list --json did not return an array', status: 'unavailable' }; + } + const row = document.find((candidate) => + isRecord(candidate) && + candidate['id'] === id && + (options.scope === undefined || candidate['scope'] === options.scope)); + if (row === undefined || !isRecord(row) || typeof row['installPath'] !== 'string') { + return { status: 'available' }; + } + return { + entry: { + installPath: row['installPath'], + ...(typeof row['version'] === 'string' ? { version: row['version'] } : {}), + }, + status: 'available', + }; + } + if (!isRecord(document) || !Array.isArray(document['installed'])) { + return { detail: 'codex plugin list --json did not return an installed array', status: 'unavailable' }; + } + const row = document['installed'].find((candidate) => + isRecord(candidate) && candidate['pluginId'] === id && candidate['installed'] !== false); + if (row === undefined || !isRecord(row) || typeof row['version'] !== 'string') { + return { status: 'available' }; + } + return { + entry: { + installPath: join(options.cacheRoot, options.marketplace, options.plugin, row['version']), + version: row['version'], + }, + status: 'available', + }; +}; + +/** Runs the host's inventory verb so replacement only uninstalls what the host reports as installed. */ +const readPublicHostInventory = async ( + runner: InstallCommandRunner, + identity: PluginIdentity, + host: Exclude, + scope: InstallScope, + environment: Readonly, + home: string, +): Promise => { + let result: InstallCommandResult; + try { + result = await runner.run(host, ['plugin', 'list', '--json'], { cwd: identity.bundleRoot }); + } catch (error) { + if (isErrno(error, 'ENOENT')) { + throw failure('AB7002', `${host} is not installed or is not available on PATH.`, host); + } + return { detail: errorMessage(error), status: 'unavailable' }; } + if (result.code !== 0) { + return { detail: result.stderr.trim() || `exit code ${result.code}`, status: 'unavailable' }; + } + return parsePublicHostInventory(host, result.stdout, { + cacheRoot: publicHostCacheRoot(host, environment, home), + marketplace: identity.marketplace ?? '', + plugin: identity.plugin, + scope, + }); }; +const publicHostUninstallArguments = ( + host: Exclude, + id: string, + scope: InstallScope, +): readonly string[] => host === 'claude' + ? ['plugin', 'uninstall', id, '--scope', scope, '--keep-data'] + : ['plugin', 'remove', id]; + const installPublicCli = async ( options: InstallBundleOptions, identity: PluginIdentity, @@ -219,6 +357,46 @@ const installPublicCli = async ( throw failure('AB7001', `${host} bundle has no marketplace identity.`, host); } const runner = options.commandRunner ?? defaultCommandRunner; + const environment = options.environment ?? process.env; + const home = options.home ?? homedir(); + const id = `${identity.plugin}@${marketplace}`; + const artifact = await treeInventory(identity.bundleRoot); + const inventory = await readPublicHostInventory(runner, identity, host, scope, environment, home); + if (inventory.status === 'unavailable' && options.replace === true) { + throw failure( + 'AB7004', + `Cannot replace the ${host} install of ${id} safely: ${host} plugin list --json was unusable (${inventory.detail}).`, + host, + ); + } + const base = { + bundleRoot: identity.bundleRoot, + contentHash: artifact.hash, + host, + marketplace, + plugin: identity.plugin, + version: identity.version, + } as const; + let replaced = false; + let previousContentHash: string | undefined; + if (inventory.status === 'available' && inventory.entry !== undefined) { + let installed: TreeInventory | undefined; + try { + installed = await treeInventory(inventory.entry.installPath); + } catch { + installed = undefined; + } + const sameVersion = inventory.entry.version === identity.version; + if (installed !== undefined && sameVersion && installed.hash === artifact.hash) { + return { ...base, destination: inventory.entry.installPath, state: 'already-installed' }; + } + const contentDrift = installed !== undefined && sameVersion && installed.hash !== artifact.hash; + if (options.replace === true || contentDrift) { + await runHostCommand(runner, identity, host, publicHostUninstallArguments(host, id, scope), 'removal'); + replaced = true; + previousContentHash = installed?.hash; + } + } await runHostCommand(runner, identity, host, [ 'plugin', 'marketplace', @@ -226,61 +404,61 @@ const installPublicCli = async ( identity.bundleRoot, ]); await runHostCommand(runner, identity, host, host === 'claude' - ? ['plugin', 'install', `${identity.plugin}@${marketplace}`, '--scope', scope] - : ['plugin', 'add', `${identity.plugin}@${marketplace}`]); + ? ['plugin', 'install', id, '--scope', scope] + : ['plugin', 'add', id]); return { - bundleRoot: identity.bundleRoot, - host, - marketplace, - plugin: identity.plugin, - state: 'installed', - version: identity.version, + ...base, + ...(previousContentHash === undefined ? {} : { previousContentHash }), + state: replaced ? 'replaced' : 'installed', }; }; -export const treeHash = async (root: string): Promise => { - const rootMetadata = await lstat(root); - if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) { - throw new Error('Refusing unsupported filesystem entry ".".'); - } - const hash = createHash('sha256'); - const visit = async (relativePath: string): Promise => { - const path = join(root, relativePath); - const metadata = await lstat(path); - if (metadata.isSymbolicLink() || (!metadata.isDirectory() && !metadata.isFile())) { - throw new Error(`Refusing unsupported filesystem entry ${JSON.stringify(relativePath || '.')}.`); - } - if (metadata.isDirectory()) { - for (const name of (await readdir(path)).sort((left, right) => left.localeCompare(right))) { - await visit(join(relativePath, name)); - } - return; - } - hash.update(relativePath.replaceAll('\\', '/')); - hash.update('\0'); - hash.update(await readFile(path)); - hash.update('\0'); - }; - for (const name of (await readdir(root)).sort((left, right) => left.localeCompare(right))) { - await visit(name); - } - return hash.digest('hex'); -}; +/** sha256 over a plugin tree in installer order; the install receipt is never part of it. */ +export const treeHash = async (root: string): Promise => (await treeInventory(root)).hash; + +const cursorManifestCandidates = Object.freeze(['.cursor-plugin/plugin.json', 'plugin.json']); -const readInstalledVersion = async (destination: string): Promise => { - for (const manifest of ['.cursor-plugin/plugin.json', 'plugin.json']) { +const readInstalledManifest = async (destination: string): Promise => { + for (const manifest of cursorManifestCandidates) { try { const document = JSON.parse(await readFile(join(destination, manifest), 'utf8')) as unknown; - if (Predicate.isObject(document) && typeof document.version === 'string') { - return document.version; + if (Predicate.isObject(document) && typeof document.name === 'string') { + return { + name: document.name, + ...(typeof document.version === 'string' ? { version: document.version } : {}), + }; } } catch (error) { - if (!isErrno(error, 'ENOENT')) throw error; + if (!isErrno(error, 'ENOENT') && !(error instanceof SyntaxError)) throw error; } } return undefined; }; +const collisionMessage = ( + destination: string, + identity: PluginIdentity, + comparison: InstalledTreeComparison, +): string => { + const detail = describeContentComparison(identity.plugin, identity.version, comparison); + switch (comparison.status) { + case 'foreign': + 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.'; + case 'current': + return `Install at ${destination} is current.`; + default: { + const exhaustive: never = comparison.status; + throw new TypeError(`Unknown install comparison ${String(exhaustive)}.`); + } + } +}; + const installCursor = async ( options: InstallBundleOptions, identity: PluginIdentity, @@ -304,51 +482,56 @@ const installCursor = async ( } const installRoot = join(cursorRoot, 'plugins', 'local'); const destination = join(installRoot, identity.plugin); + const base = { + bundleRoot: identity.bundleRoot, + destination, + host: 'cursor', + plugin: identity.plugin, + version: identity.version, + } as const; try { - await treeHash(identity.bundleRoot); + const artifact = await treeInventory(identity.bundleRoot); await mkdir(installRoot, { recursive: true }); - if (await exists(destination)) { - const currentVersion = await readInstalledVersion(destination); - if (currentVersion !== undefined && currentVersion !== identity.version) { - throw failure( - 'AB7005', - `Refusing version collision at ${destination}: found ${currentVersion}, requested ${identity.version}.`, - 'cursor', - ); - } - if (await treeHash(identity.bundleRoot) === await treeHash(destination)) { - return { - bundleRoot: identity.bundleRoot, - destination, - host: 'cursor', - plugin: identity.plugin, - state: 'already-installed', - version: identity.version, - }; + const receipt = { host: 'cursor', plugin: identity.plugin, version: identity.version } as const; + if (!await exists(destination)) { + const staged = await stageArtifact({ artifactRoot: identity.bundleRoot, destination, receipt, stageRoot: installRoot }); + try { + await rename(staged.root, destination); + } finally { + await rm(staged.parent, { force: true, recursive: true }); } - throw failure('AB7005', `Refusing content collision at ${destination}.`, 'cursor'); + return { ...base, contentHash: artifact.hash, state: 'installed' }; } - const stageParent = await mkdtemp(join(installRoot, `.${basename(destination)}.stage-`)); - const stage = join(stageParent, 'bundle'); - try { - await cp(identity.bundleRoot, stage, { - errorOnExist: true, - force: false, - recursive: true, - verbatimSymlinks: true, - }); - await treeHash(stage); - await rename(stage, destination); - } finally { - await rm(stageParent, { force: true, recursive: true }); + if (resolve(identity.bundleRoot) === destination) { + return { ...base, contentHash: artifact.hash, state: 'already-installed' }; } - return { - bundleRoot: identity.bundleRoot, + const comparison = await compareInstalledTree({ + artifact, destination, - host: 'cursor', + installedManifest: await readInstalledManifest(destination), plugin: identity.plugin, - state: 'installed', version: identity.version, + }); + if (comparison.status === 'current') { + return { ...base, contentHash: artifact.hash, state: 'already-installed' }; + } + const replaceable = comparison.status === 'stale' && comparison.ownership === 'receipt' + ? true + : comparison.status !== 'foreign' && options.replace === true; + if (!replaceable) { + throw failure('AB7005', collisionMessage(destination, identity, comparison), 'cursor'); + } + const staged = await stageArtifact({ artifactRoot: identity.bundleRoot, destination, receipt, stageRoot: installRoot }); + try { + await replaceInstalledTree({ comparison, destination, staged }); + } finally { + await rm(staged.parent, { force: true, recursive: true }); + } + return { + ...base, + contentHash: artifact.hash, + previousContentHash: comparison.installedContentHash, + state: 'replaced', }; } catch (error) { if (error instanceof DiagnosticError) throw error; diff --git a/packages/agent-bundle/src/install/receipt.ts b/packages/agent-bundle/src/install/receipt.ts new file mode 100644 index 000000000..b8deb4d60 --- /dev/null +++ b/packages/agent-bundle/src/install/receipt.ts @@ -0,0 +1,379 @@ +import { createHash } from 'node:crypto'; +import { + cp, + lstat, + mkdir, + mkdtemp, + readFile, + readdir, + rename, + rm, + rmdir, + writeFile, +} from 'node:fs/promises'; +import { basename, dirname, join } from 'node:path'; + +import { stableJson } from '../core/digest.ts'; +import { isErrno } from '../core/errors.ts'; +import { exists, safeArtifactPath } from '../core/paths.ts'; + +/** + * Host-agnostic install ownership core shared by `agent-bundle install`, + * `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. + */ + +/** Sidecar written at an installed plugin root by every agent-bundle installer. */ +export const installReceiptFile = '.agent-bundle-install.json'; + +export const installReceiptFormat = 'agent-bundle-install-receipt/1'; + +/** Root entries owned by generated runtime code; installers never remove or rewrite them. */ +export const preservedRuntimeEntries: readonly string[] = Object.freeze(['state']); + +/** + * 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']); + +export interface InstallReceipt { + readonly contentHash: string; + readonly files: readonly string[]; + readonly format: typeof installReceiptFormat; + readonly host: string; + readonly installedAt: string; + readonly plugin: string; + readonly version: string; +} + +export interface TreeInventory { + /** Regular files in deterministic traversal order, POSIX-relative to the root. */ + readonly files: readonly string[]; + /** sha256 over `path\0bytes\0` for every file in `files`; the receipt is never included. */ + readonly hash: string; +} + +export type InstalledTreeOwnership = 'foreign' | 'legacy' | 'receipt'; + +export type InstalledTreeStatus = 'current' | 'foreign' | 'stale' | 'version-mismatch'; + +export interface InstalledManifestIdentity { + readonly name: string; + readonly version?: string; +} + +export interface InstalledTreeComparison { + readonly artifactContentHash: string; + readonly installedContentHash: string; + readonly installedName?: string; + readonly installedVersion?: string; + readonly ownership: InstalledTreeOwnership; + readonly receipt?: InstallReceipt; + readonly status: InstalledTreeStatus; +} + +const unsupportedEntry = (relativePath: string): Error => + new Error(`Refusing unsupported filesystem entry ${JSON.stringify(relativePath || '.')}.`); + +const sortNames = (names: readonly string[]): readonly string[] => + [...names].sort((left, right) => left.localeCompare(right)); + +const toPosix = (path: string): string => path.replaceAll('\\', '/'); + +const hashEntry = (hash: ReturnType, relativePath: string, bytes: Uint8Array): void => { + hash.update(toPosix(relativePath)); + hash.update('\0'); + hash.update(bytes); + hash.update('\0'); +}; + +/** + * Walks a plugin tree the way the installers copy it: symlinks and special + * files are refused, directories recurse in locale order, and the receipt at + * the root is skipped so an installed copy hashes like the artifact it came from. + */ +export const treeInventory = async (root: string): Promise => { + const rootMetadata = await lstat(root); + if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) throw unsupportedEntry('.'); + const hash = createHash('sha256'); + const files: string[] = []; + const visit = async (relativePath: string): Promise => { + const path = join(root, relativePath); + const metadata = await lstat(path); + if (metadata.isSymbolicLink() || (!metadata.isDirectory() && !metadata.isFile())) { + throw unsupportedEntry(relativePath); + } + if (metadata.isDirectory()) { + for (const name of sortNames(await readdir(path))) await visit(join(relativePath, name)); + return; + } + files.push(toPosix(relativePath)); + hashEntry(hash, relativePath, await readFile(path)); + }; + for (const name of sortNames(await readdir(root))) { + if (name === installReceiptFile) continue; + await visit(name); + } + return Object.freeze({ files: Object.freeze(files), hash: hash.digest('hex') }); +}; + +/** + * Hashes exactly the listed files (in list order) as they exist under root. + * A missing owned file contributes nothing, so the digest differs from the + * artifact digest whenever an owned file was removed or rewritten. + */ +export const hashOwnedFiles = async (root: string, files: readonly string[]): Promise => { + const hash = createHash('sha256'); + for (const relativePath of files) { + const path = join(root, relativePath); + let metadata: Awaited>; + try { + metadata = await lstat(path); + } catch (error) { + if (isErrno(error, 'ENOENT')) continue; + throw error; + } + if (metadata.isSymbolicLink() || !metadata.isFile()) throw unsupportedEntry(relativePath); + hashEntry(hash, relativePath, await readFile(path)); + } + return hash.digest('hex'); +}; + +const isReceiptFileList = (value: unknown): value is readonly string[] => + Array.isArray(value) && + value.every((entry) => + typeof entry === 'string' && + safeArtifactPath(entry) && + entry !== installReceiptFile); + +/** Reads the receipt at a plugin root; malformed or unsafe receipts read as absent. */ +export const readInstallReceipt = async (destination: string): Promise => { + let value: unknown; + try { + value = JSON.parse(await readFile(join(destination, installReceiptFile), 'utf8')) as unknown; + } catch (error) { + if (isErrno(error, 'ENOENT') || error instanceof SyntaxError) return undefined; + throw error; + } + if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined; + const record = value as Record; + if ( + record['format'] !== installReceiptFormat || + typeof record['plugin'] !== 'string' || + typeof record['version'] !== 'string' || + typeof record['host'] !== 'string' || + typeof record['contentHash'] !== 'string' || + typeof record['installedAt'] !== 'string' || + !isReceiptFileList(record['files']) + ) { + return undefined; + } + return Object.freeze({ + contentHash: record['contentHash'], + files: Object.freeze([...record['files']]), + format: installReceiptFormat, + host: record['host'], + installedAt: record['installedAt'], + plugin: record['plugin'], + version: record['version'], + }); +}; + +export const createInstallReceipt = (options: { + readonly host: string; + readonly installedAt?: string; + readonly inventory: TreeInventory; + readonly plugin: string; + readonly version: string; +}): InstallReceipt => Object.freeze({ + contentHash: options.inventory.hash, + files: options.inventory.files, + format: installReceiptFormat, + host: options.host, + installedAt: options.installedAt ?? new Date().toISOString(), + plugin: options.plugin, + version: options.version, +}); + +const receiptDocument = (receipt: InstallReceipt): string => `${stableJson(receipt)}\n`; + +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. + */ +export const compareInstalledTree = async (options: { + readonly artifact: TreeInventory; + readonly destination: string; + readonly installedManifest?: InstalledManifestIdentity; + readonly plugin: string; + readonly version: string; +}): Promise => { + const rootMetadata = await lstat(options.destination); + if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) throw unsupportedEntry('.'); + const receipt = await readInstallReceipt(options.destination); + const manifest = options.installedManifest; + let ownership: InstalledTreeOwnership; + let installedContentHash: string; + if (receipt !== undefined && receipt.plugin === options.plugin) { + ownership = 'receipt'; + 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'; + } + const installedVersion = manifest?.version ?? (ownership === 'receipt' ? receipt?.version : undefined); + const installedName = manifest?.name ?? (ownership === 'receipt' ? receipt?.plugin : undefined); + const status: InstalledTreeStatus = installedContentHash === options.artifact.hash + ? 'current' + : ownership === 'foreign' + ? 'foreign' + : installedVersion !== undefined && installedVersion !== options.version + ? 'version-mismatch' + : 'stale'; + return Object.freeze({ + artifactContentHash: options.artifact.hash, + installedContentHash, + ...(installedName === undefined ? {} : { installedName }), + ...(installedVersion === undefined ? {} : { installedVersion }), + ownership, + ...(ownership === 'receipt' && receipt !== undefined ? { receipt } : {}), + status, + }); +}; + +/** Short hash prefix for operator-facing messages; full hashes ride the machine output. */ +export const shortHash = (hash: string): string => hash.slice(0, 12); + +/** The operator-facing installed-versus-artifact sentence shared by installers and Doctor. */ +export const describeContentComparison = ( + plugin: string, + version: string, + comparison: Pick< + InstalledTreeComparison, + 'artifactContentHash' | 'installedContentHash' | 'installedName' | 'installedVersion' | 'status' + >, +): string => { + const installedVersion = comparison.installedVersion ?? 'unknown version'; + const verdict = comparison.status === 'current' + ? 'same content' + : comparison.installedVersion === undefined + ? 'different content, installed version unknown' + : comparison.installedVersion === version + ? 'same version, different content' + : 'different version'; + return `installed ${comparison.installedName ?? plugin}@${installedVersion} ` + + `content ${shortHash(comparison.installedContentHash)} vs artifact ${plugin}@${version} ` + + `content ${shortHash(comparison.artifactContentHash)} (${verdict})`; +}; + +export interface StagedArtifact { + readonly inventory: TreeInventory; + readonly parent: string; + readonly root: string; +} + +/** + * Copies the artifact into a sibling staging directory on the destination's + * filesystem (so every later `rename` is atomic), refuses symlinks, and lands + * the receipt inside the staged copy. + */ +export const stageArtifact = async (options: { + readonly artifactRoot: string; + readonly destination: string; + readonly receipt: { readonly host: string; readonly installedAt?: string; readonly plugin: string; readonly version: string }; + readonly stageRoot: string; +}): Promise => { + const parent = await mkdtemp(join(options.stageRoot, `.${basename(options.destination)}.stage-`)); + const root = join(parent, 'bundle'); + try { + await cp(options.artifactRoot, root, { + errorOnExist: true, + force: false, + recursive: true, + verbatimSymlinks: true, + }); + await rm(join(root, installReceiptFile), { force: true }); + const inventory = await treeInventory(root); + await writeFile( + join(root, installReceiptFile), + receiptDocument(createInstallReceipt({ ...options.receipt, inventory })), + 'utf8', + ); + return Object.freeze({ inventory, parent, root }); + } catch (error) { + await rm(parent, { force: true, recursive: true }); + throw error; + } +}; + +const pruneEmptyDirectories = async (destination: string, removedFiles: readonly string[]): Promise => { + const candidates = new Set(); + for (const file of removedFiles) { + let directory = dirname(file); + while (directory !== '.' && directory !== '') { + candidates.add(directory); + directory = dirname(directory); + } + } + for (const directory of [...candidates].sort((left, right) => right.length - left.length)) { + try { + await rmdir(join(destination, directory)); + } catch (error) { + if (!isErrno(error, 'ENOTEMPTY') && !isErrno(error, 'ENOENT') && !isErrno(error, 'EEXIST')) throw error; + } + } +}; + +const previouslyOwnedFiles = async ( + destination: string, + comparison: InstalledTreeComparison, +): Promise => { + if (comparison.ownership === 'receipt' && comparison.receipt !== undefined) return comparison.receipt.files; + const inventory = await treeInventory(destination); + return inventory.files.filter((file) => !preservedRuntimeEntries.includes(file.split('/')[0] ?? '')); +}; + +/** + * Replaces an agent-bundle-owned install in place: stale owned files leave + * first (their now-empty directories are pruned), every staged file then + * moves over its predecessor with an atomic rename, and the receipt lands + * last as the commit marker. Unowned entries are never touched. + */ +export const replaceInstalledTree = async (options: { + readonly comparison: InstalledTreeComparison; + readonly destination: string; + readonly staged: StagedArtifact; +}): Promise => { + if (options.comparison.ownership === 'foreign') { + throw new Error(`Refusing to replace foreign install at ${options.destination}.`); + } + const incoming = new Set(options.staged.inventory.files); + const stale = (await previouslyOwnedFiles(options.destination, options.comparison)) + .filter((file) => !incoming.has(file)); + for (const file of stale) { + await rm(join(options.destination, file), { force: true }); + } + await pruneEmptyDirectories(options.destination, stale); + for (const file of options.staged.inventory.files) { + const target = join(options.destination, file); + await mkdir(dirname(target), { recursive: true }); + await rename(join(options.staged.root, file), target); + } + await rename(join(options.staged.root, installReceiptFile), join(options.destination, installReceiptFile)); +}; diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index ed89ce424..5cc2fc18d 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -5,11 +5,19 @@ import { type TargetArtifactPlan, type TargetArtifactWrite, } from '../adapters/types.ts'; +import { + installReceiptFile, + installReceiptFormat, + installSurfaceMarkerFiles, + preservedRuntimeEntries, +} from './receipt.ts'; export type BuiltInTarget = 'claude' | 'codex' | 'cursor' | 'plugin' | 'portable'; const marketplaceName = (model: NormalizedPlugin): string => `${model.metadata.name}-marketplace`; +const pluginId = (model: NormalizedPlugin): string => `${model.metadata.name}@${marketplaceName(model)}`; + const header = (model: NormalizedPlugin): string[] => [ `# Install ${model.metadata.name}`, '', @@ -33,6 +41,21 @@ const claudeInstructions = (model: NormalizedPlugin): string[] => [ '', 'Replace `user` with `project` or `local` when that Claude scope is intended.', '', + '### Reinstall after a same-version rebuild', + '', + '`claude plugin update` is version-gated: when the bundle content changed but `version` did not,', + 'it reports the plugin is already at the latest version and leaves the cached copy stale.', + 'Uninstall and install again instead (`--keep-data` preserves the plugin\'s persistent data):', + '', + '```sh', + `claude plugin uninstall ${pluginId(model)} --scope user --keep-data`, + 'claude plugin marketplace add ./', + `claude plugin install ${pluginId(model)} --scope user`, + '```', + '', + '`agent-bundle install claude --from ./` runs this sequence automatically when the installed', + 'copy has the same version but a different content hash; `--replace` (alias `--force`) forces it.', + '', ]; const codexInstructions = (model: NormalizedPlugin): string[] => [ @@ -45,6 +68,20 @@ const codexInstructions = (model: NormalizedPlugin): string[] => [ `codex plugin add ${model.metadata.name}@${marketplaceName(model)}`, '```', '', + '### Reinstall after a same-version rebuild', + '', + '`codex plugin add` re-copies the marketplace snapshot but never deletes files a rebuild removed.', + 'Remove and add again for a clean same-version copy:', + '', + '```sh', + `codex plugin remove ${pluginId(model)}`, + 'codex plugin marketplace add ./', + `codex plugin add ${pluginId(model)}`, + '```', + '', + '`agent-bundle install codex --from ./` runs this sequence automatically when the installed', + 'copy has the same version but a different content hash; `--replace` (alias `--force`) forces it.', + '', ]; const cursorInstructions = (model: NormalizedPlugin): string[] => [ @@ -57,8 +94,23 @@ const cursorInstructions = (model: NormalizedPlugin): string[] => [ '```', '', `It installs to \`~/.cursor/plugins/local/${model.metadata.name}\`. Restart Cursor or run`, - '`Developer: Reload Window` after installation. The installer never overwrites a different', - 'version or different content.', + '`Developer: Reload Window` after installation.', + '', + '### Reinstall after a same-version rebuild', + '', + `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:', + '', + '```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', + '```', + '', + '`--force` is an alias for `--replace`. 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. `agent-bundle install cursor --from ./ [--replace]` applies the same policy.', '', ]; @@ -75,6 +127,15 @@ const portableInstructions = (): string[] => [ 'node ./install.mjs', '```', '', + '### 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` (alias `--force`) 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 Codex and other native clients, remove and re-add the plugin through the client', + 'when only content changed at the same version.', + '', ]; const installMarkdown = (model: NormalizedPlugin, target: BuiltInTarget): string => { @@ -99,89 +160,241 @@ const installMarkdown = (model: NormalizedPlugin, target: BuiltInTarget): string return [...header(model), ...sections].join('\n'); }; +/** + * 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 + * owned-files-only in-place replacement. `tests/install-surface.test.ts` and + * the host-install proofs pin the two implementations to each other. + */ const cursorInstallerSource = (model: NormalizedPlugin): string => { const name = JSON.stringify(model.metadata.name); const version = JSON.stringify(model.metadata.version); return [ '#!/usr/bin/env node', "import { createHash } from 'node:crypto';", - "import { cp, lstat, mkdir, mkdtemp, readFile, readdir, rename, rm } from 'node:fs/promises';", + "import { cp, lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, rmdir, writeFile } from 'node:fs/promises';", "import { homedir } from 'node:os';", - "import { basename, join, resolve } from 'node:path';", + "import { basename, dirname, isAbsolute, join, resolve } from 'node:path';", "import { fileURLToPath } from 'node:url';", '', `const pluginName = ${name};`, `const pluginVersion = ${version};`, + `const receiptFile = ${JSON.stringify(installReceiptFile)};`, + `const receiptFormat = ${JSON.stringify(installReceiptFormat)};`, + `const preservedEntries = ${JSON.stringify(preservedRuntimeEntries)};`, + `const markerFiles = ${JSON.stringify(installSurfaceMarkerFiles)};`, "const source = resolve(fileURLToPath(new URL('.', import.meta.url)));", "const cursorRoot = join(homedir(), '.cursor');", "const installRoot = join(cursorRoot, 'plugins', 'local');", 'const destination = join(installRoot, pluginName);', + "const usage = 'Usage: node install.mjs [--replace|--force] [--help]';", + '', + 'let replace = false;', + 'for (const argument of process.argv.slice(2)) {', + " if (argument === '--replace' || argument === '--force') { replace = true; continue; }", + " if (argument === '--help' || argument === '-h') { console.log(usage); process.exit(0); }", + ' console.error(`Unknown installer argument ${JSON.stringify(argument)}.\\n${usage}`);', + ' process.exit(2);', + '}', '', 'const exists = async (path) => {', ' try { await lstat(path); return true; }', " catch (error) { if (error?.code === 'ENOENT') return false; throw error; }", '};', + "const unsupported = (relative) => new Error(`Refusing unsupported filesystem entry ${JSON.stringify(relative || '.')}.`);", + "const toPosix = (path) => path.replaceAll('\\\\', '/');", + 'const short = (hash) => hash.slice(0, 12);', '', - 'const treeHash = async (root, prefix = \'\') => {', + 'const hashEntry = (hash, relative, bytes) => {', + ' hash.update(toPosix(relative));', + " hash.update('\\0');", + ' hash.update(bytes);', + " hash.update('\\0');", + '};', + '', + '// Deterministic tree walk: symlinks and special files refused, the root receipt skipped.', + 'const inventory = async (root) => {', ' const rootMetadata = await lstat(root);', - ' if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) {', - ' throw new Error(\'Refusing unsupported filesystem entry ".".\');', - ' }', + " if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) throw unsupported('.');", " const hash = createHash('sha256');", + ' const files = [];', ' const visit = async (relative) => {', ' const absolute = join(root, relative);', ' const metadata = await lstat(absolute);', - ' if (metadata.isSymbolicLink() || (!metadata.isDirectory() && !metadata.isFile())) {', - " throw new Error(`Refusing unsupported filesystem entry ${JSON.stringify(relative || '.')}.`);", - ' }', + ' if (metadata.isSymbolicLink() || (!metadata.isDirectory() && !metadata.isFile())) throw unsupported(relative);', ' if (metadata.isDirectory()) {', - ' for (const name of (await readdir(absolute)).sort()) await visit(join(relative, name));', + ' for (const entry of (await readdir(absolute)).sort((left, right) => left.localeCompare(right))) await visit(join(relative, entry));', ' return;', ' }', - " hash.update(relative.replaceAll('\\\\', '/'));", - " hash.update('\\0');", - ' hash.update(await readFile(absolute));', - " hash.update('\\0');", + ' files.push(toPosix(relative));', + ' hashEntry(hash, relative, await readFile(absolute));', ' };', - " for (const name of (await readdir(root)).sort()) await visit(join(prefix, name));", + ' for (const entry of (await readdir(root)).sort((left, right) => left.localeCompare(right))) {', + ' if (entry === receiptFile) continue;', + ' await visit(entry);', + ' }', + " return { files, hash: hash.digest('hex') };", + '};', + '', + '// Hashes exactly the owned files; a missing owned file simply changes the digest.', + 'const hashOwned = async (root, files) => {', + " const hash = createHash('sha256');", + ' for (const relative of files) {', + ' const absolute = join(root, relative);', + ' let metadata;', + " try { metadata = await lstat(absolute); } catch (error) { if (error?.code === 'ENOENT') continue; throw error; }", + ' if (metadata.isSymbolicLink() || !metadata.isFile()) throw unsupported(relative);', + ' hashEntry(hash, relative, await readFile(absolute));', + ' }', " return hash.digest('hex');", '};', '', - 'const installedVersion = async () => {', + "const safeRelative = (value) => typeof value === 'string' && value.length > 0 && !isAbsolute(value) &&", + " value !== receiptFile && !value.startsWith('./') && value.split('/').every((segment) => segment !== '' && segment !== '..');", + '', + 'const readReceipt = async (root) => {', + ' let value;', + " try { value = JSON.parse(await readFile(join(root, receiptFile), '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 || typeof value.plugin !== 'string' || typeof value.version !== 'string' ||", + " typeof value.contentHash !== 'string' || !Array.isArray(value.files) || !value.files.every(safeRelative)) return undefined;", + ' return value;', + '};', + '', + 'const readManifest = async (root) => {', " for (const manifest of ['.cursor-plugin/plugin.json', 'plugin.json']) {", ' try {', - " const value = JSON.parse(await readFile(join(destination, manifest), 'utf8'));", - " if (typeof value.version === 'string') return value.version;", - " } catch (error) { if (error?.code !== 'ENOENT') throw error; }", + " const value = JSON.parse(await readFile(join(root, manifest), 'utf8'));", + " if (value !== null && typeof value === 'object' && typeof value.name === 'string') {", + " return { name: value.name, ...(typeof value.version === 'string' ? { version: value.version } : {}) };", + ' }', + " } catch (error) { if (error?.code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error; }", ' }', ' return undefined;', '};', '', + 'const hasMarkers = async (root) => {', + ' for (const marker of markerFiles) if (!(await exists(join(root, marker)))) return false;', + ' return true;', + '};', + '', + '// Staged sibling copy on the destination filesystem so every later rename is atomic.', + 'const stage = async () => {', + ' const parent = await mkdtemp(join(installRoot, `.${basename(destination)}.stage-`));', + " const root = join(parent, 'bundle');", + ' try {', + ' await cp(source, root, { errorOnExist: true, force: false, recursive: true, verbatimSymlinks: true });', + ' await rm(join(root, receiptFile), { force: true });', + ' const staged = await inventory(root);', + ' const receipt = {', + ' contentHash: staged.hash,', + ' files: staged.files,', + ' format: receiptFormat,', + " host: 'cursor',", + ' installedAt: new Date().toISOString(),', + ' plugin: pluginName,', + ' version: pluginVersion,', + ' };', + " await writeFile(join(root, receiptFile), `${JSON.stringify(receipt, null, 2)}\\n`, 'utf8');", + ' return { inventory: staged, parent, root };', + ' } catch (error) {', + ' await rm(parent, { force: true, recursive: true });', + ' throw error;', + ' }', + '};', + '', + 'const pruneEmptyDirectories = async (removed) => {', + ' const candidates = new Set();', + ' for (const file of removed) {', + ' let directory = dirname(file);', + " while (directory !== '.' && directory !== '') { candidates.add(directory); directory = dirname(directory); }", + ' }', + ' for (const directory of [...candidates].sort((left, right) => right.length - left.length)) {', + ' try { await rmdir(join(destination, directory)); }', + " catch (error) { if (!['ENOTEMPTY', 'ENOENT', 'EEXIST'].includes(error?.code)) throw error; }", + ' }', + '};', + '', 'if (!(await exists(cursorRoot)) || !(await lstat(cursorRoot)).isDirectory()) {', ' throw new Error(`Cursor is not installed in ${cursorRoot}.`);', '}', 'await mkdir(installRoot, { recursive: true });', - 'if (await exists(destination)) {', - ' const currentVersion = await installedVersion();', - ' if (currentVersion !== undefined && currentVersion !== pluginVersion) {', - ' throw new Error(`Refusing version collision at ${destination}: found ${currentVersion}, requested ${pluginVersion}.`);', - ' }', - ' if (source === destination || await treeHash(source) === await treeHash(destination)) {', - ' console.log(`Already installed ${pluginName}@${pluginVersion} at ${destination}`);', - ' process.exit(0);', + 'const artifact = await inventory(source);', + '', + 'if (!(await exists(destination))) {', + ' const staged = await stage();', + ' try {', + ' await rename(staged.root, destination);', + ' console.log(`Installed ${pluginName}@${pluginVersion} at ${destination} (content ${short(artifact.hash)})`);', + ' } finally {', + ' await rm(staged.parent, { force: true, recursive: true });', ' }', - ' throw new Error(`Refusing content collision at ${destination}.`);', + ' process.exit(0);', + '}', + 'if (source === destination) {', + ' console.log(`Already installed ${pluginName}@${pluginVersion} at ${destination} (content ${short(artifact.hash)})`);', + ' process.exit(0);', '}', '', - 'const stageParent = await mkdtemp(join(installRoot, `.${basename(destination)}.stage-`));', - "const stage = join(stageParent, 'bundle');", + 'const destinationMetadata = await lstat(destination);', + "if (destinationMetadata.isSymbolicLink() || !destinationMetadata.isDirectory()) throw unsupported('.');", + 'const receipt = await readReceipt(destination);', + 'const manifest = await readManifest(destination);', + 'let ownership;', + 'let installedHash;', + 'if (receipt !== undefined && receipt.plugin === pluginName) {', + " ownership = 'receipt';", + ' installedHash = await hashOwned(destination, receipt.files);', + '} else {', + ' installedHash = (await inventory(destination)).hash;', + " ownership = receipt === undefined && manifest?.name === pluginName && await hasMarkers(destination) ? 'legacy' : 'foreign';", + '}', + "const installedVersion = manifest?.version ?? (ownership === 'receipt' ? receipt.version : undefined);", + "const installedName = manifest?.name ?? (ownership === 'receipt' ? receipt.plugin : pluginName);", + 'if (installedHash === artifact.hash) {', + ' console.log(`Already installed ${pluginName}@${pluginVersion} at ${destination} (content ${short(artifact.hash)})`);', + ' process.exit(0);', + '}', + 'const verdict = installedVersion === undefined', + " ? 'different content, installed version unknown'", + " : installedVersion === pluginVersion ? 'same version, different content' : 'different version';", + "const detail = `installed ${installedName}@${installedVersion ?? 'unknown version'} content ${short(installedHash)} ` +", + ' `vs artifact ${pluginName}@${pluginVersion} content ${short(artifact.hash)} (${verdict})`;', + "if (ownership === 'foreign') {", + ' throw new Error(`Refusing foreign install at ${destination}: ${detail}; the directory is not an agent-bundle install of ` +', + ' `${pluginName}, so --replace does not apply. Remove it manually if it is stale.`);', + '}', + '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) {", + ' 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();', 'try {', - ' await cp(source, stage, { errorOnExist: true, force: false, recursive: true, verbatimSymlinks: true });', - ' await treeHash(stage);', - ' await rename(stage, destination);', - ' console.log(`Installed ${pluginName}@${pluginVersion} at ${destination}`);', + " const owned = ownership === 'receipt'", + ' ? receipt.files', + " : (await inventory(destination)).files.filter((file) => !preservedEntries.includes(file.split('/')[0]));", + ' const incoming = new Set(staged.inventory.files);', + ' const stale = owned.filter((file) => !incoming.has(file));', + ' for (const file of stale) await rm(join(destination, file), { force: true });', + ' await pruneEmptyDirectories(stale);', + ' for (const file of staged.inventory.files) {', + ' const target = join(destination, file);', + ' await mkdir(dirname(target), { recursive: true });', + ' await rename(join(staged.root, file), target);', + ' }', + ' await rename(join(staged.root, receiptFile), join(destination, receiptFile));', + ' console.log(`Replaced ${pluginName}@${pluginVersion} at ${destination} (content ${short(installedHash)} -> ${short(artifact.hash)})`);', '} finally {', - ' await rm(stageParent, { force: true, recursive: true });', + ' await rm(staged.parent, { force: true, recursive: true });', '}', '', ].join('\n'); diff --git a/packages/agent-bundle/tests/cli.test.ts b/packages/agent-bundle/tests/cli.test.ts index dee0bcdd9..c71d1ba5b 100644 --- a/packages/agent-bundle/tests/cli.test.ts +++ b/packages/agent-bundle/tests/cli.test.ts @@ -697,6 +697,7 @@ it('dispatches the install command through the native installer surface', async expect(calls).toEqual([{ from: '/tmp/example bundle', host: 'claude', + replace: false, scope: 'project', }]); expect(JSON.parse(stdout.join(''))).toMatchObject({ diff --git a/packages/agent-bundle/tests/doctor.test.ts b/packages/agent-bundle/tests/doctor.test.ts index 85e8c5364..627777464 100644 --- a/packages/agent-bundle/tests/doctor.test.ts +++ b/packages/agent-bundle/tests/doctor.test.ts @@ -8,6 +8,8 @@ import { expect, it } from '@rstest/core'; import { runCli } from '../src/cli.ts'; import { eventRuntimeEndpoint } from '../src/events/ipc.ts'; +import { installBundle } from '../src/install/install.ts'; +import { treeInventory } from '../src/install/receipt.ts'; import { doctorEndpointDirectory, doctorEndpointProbeConcurrency, @@ -111,7 +113,13 @@ const createBundle = async ( }), ]); } else { - await writeJson(join(bundle, '.cursor-plugin/plugin.json'), { name: 'doctor-fixture', version }); + // 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. + await Promise.all([ + writeJson(join(bundle, '.cursor-plugin/plugin.json'), { name: 'doctor-fixture', version }), + writeFile(join(bundle, 'INSTALL.md'), '# Install doctor-fixture\n'), + writeFile(join(bundle, 'install.mjs'), '// installer\n'), + ]); } return bundle; }; @@ -670,8 +678,10 @@ it('validates --from Codex bytes without running the live schema generator', asy hosts: ['codex'], }); + // Read-only inventory only: the version probe and the pinned `plugin list --json`, never the schema generator. expect(calls).toEqual([ expect.objectContaining({ args: ['--version'], executable: 'codex' }), + expect.objectContaining({ args: ['plugin', 'list', '--json'], cwd: bundle, executable: 'codex' }), ]); expect(hostReport(report, 'codex').bundle?.state).toBe('corrupt'); expect(report.diagnostics).toEqual(expect.arrayContaining([ @@ -710,12 +720,14 @@ it('validates --from Claude documents from pinned bytes without a new CLI proof' hosts: ['claude'], }); + // Read-only proofs only: version probe, inline registration proof, installed inventory; never `plugin validate`. expect(calls).toEqual([ expect.objectContaining({ args: ['--version'], executable: 'claude' }), expect.objectContaining({ args: ['--plugin-dir', bundle, 'plugin', 'list', '--json'], executable: 'claude', }), + expect.objectContaining({ args: ['plugin', 'list', '--json'], cwd: bundle, executable: 'claude' }), ]); expect(hostReport(report, 'claude').bundle?.state).toBe('corrupt'); expect(report.diagnostics).toEqual(expect.arrayContaining([ @@ -846,6 +858,99 @@ it('classifies Cursor bundle state as installed, missing, drifted, or conflicted } }); +it('compares the installed Cursor copy against the artifact: current, stale, foreign, not installed', 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(join(fixture.home, '.cursor'), { recursive: true }); + const doctor = () => runDoctor({ + endpointDirectory: fixture.endpointDirectory, + from: bundle, + home: fixture.home, + hosts: ['cursor'], + }); + const artifactHash = (await treeInventory(bundle)).hash; + + const missing = hostReport(await doctor(), 'cursor'); + expect(missing.bundle).toMatchObject({ + comparison: { artifactContentHash: artifactHash, status: 'not-installed' }, + state: 'missing', + }); + expect(missing.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'AB7307', severity: 'info' }), + ])); + + // Receipt-managed install: current, then stale after a same-version content change. + await installBundle({ from: bundle, home: fixture.home, host: 'cursor', scope: 'user' }); + const current = hostReport(await doctor(), 'cursor'); + expect(current.bundle).toMatchObject({ + comparison: { + artifactContentHash: artifactHash, + installedContentHash: artifactHash, + installedPath: destination, + installedVersion: '1.2.3', + ownership: 'receipt', + status: 'current', + }, + state: 'installed', + }); + expect(current.diagnostics.filter((entry) => entry.severity !== 'info')).toEqual([]); + + await writeFile(join(bundle, 'payload.txt'), 'rebuilt\n'); + const rebuiltHash = (await treeInventory(bundle)).hash; + const stale = hostReport(await doctor(), 'cursor'); + expect(stale.bundle).toMatchObject({ + comparison: { + artifactContentHash: rebuiltHash, + installedContentHash: artifactHash, + ownership: 'receipt', + status: 'stale', + }, + state: 'drifted', + }); + const staleDiagnostic = stale.diagnostics.find((entry) => entry.code === 'AB7308'); + expect(staleDiagnostic).toMatchObject({ severity: 'warning', target: 'cursor' }); + expect(staleDiagnostic?.message).toContain('stale (same version, different content)'); + expect(staleDiagnostic?.message).toContain(`content ${artifactHash.slice(0, 12)}`); + 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. + await rm(destination, { force: true, recursive: true }); + 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'); + + // Foreign directory under the plugin name: no receipt, no install surface. + await rm(destination, { force: true, recursive: true }); + await mkdir(destination, { recursive: true }); + await writeJson(join(destination, '.cursor-plugin/plugin.json'), { name: 'doctor-fixture', version: '1.2.3' }); + await writeFile(join(destination, 'payload.txt'), 'someone else\n'); + const foreignHash = (await treeInventory(destination)).hash; + const foreign = hostReport(await doctor(), 'cursor'); + expect(foreign.bundle).toMatchObject({ + comparison: { + artifactContentHash: rebuiltHash, + installedContentHash: foreignHash, + ownership: 'foreign', + status: 'foreign', + }, + state: 'conflicted', + }); + const foreignDiagnostic = foreign.diagnostics.find((entry) => entry.code === 'AB7321'); + expect(foreignDiagnostic).toMatchObject({ severity: 'warning', target: 'cursor' }); + expect(foreignDiagnostic?.message).toContain('foreign install'); + expect(foreignDiagnostic?.message).toContain(`content ${foreignHash.slice(0, 12)}`); + expect(foreignDiagnostic?.message).toContain(`content ${rebuiltHash.slice(0, 12)}`); + expect(foreignDiagnostic?.message).toContain('same version, different content'); + } finally { + await fixture.cleanup(); + } +}); + it('treats a versionless Cursor destination as drifted rather than conflicted', async () => { const fixture = await temporaryDoctor(); try { @@ -1001,6 +1106,7 @@ it('reports Codex bundle registration as unknown', async () => { hosts: ['codex'], }); expect(hostReport(report, 'codex').bundle?.state).toBe('unknown'); + expect(hostReport(report, 'codex').bundle?.comparison).toMatchObject({ status: 'unknown' }); expect(report.diagnostics).toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB7313', severity: 'info' }), ])); @@ -1009,6 +1115,134 @@ it('reports Codex bundle registration as unknown', async () => { } }); +const isInventoryRequest = (request: Parameters[0]): boolean => + request.args.join(' ') === 'plugin list --json'; + +it('compares the Claude cache copy reported by plugin list --json against the artifact', async () => { + const fixture = await temporaryDoctor(); + try { + const bundle = await createBundle(fixture.root, 'claude'); + const installed = join(fixture.root, 'claude-config', 'plugins', 'cache', 'doctor-fixture-marketplace', 'doctor-fixture', '1.2.3'); + await cp(bundle, installed, { recursive: true }); + const artifactHash = (await treeInventory(bundle)).hash; + let inventory: readonly unknown[] = []; + const runner: DoctorCommandRunner = async (request) => { + if (request.args[0] === '--version') return commandResult({ stdout: 'claude 2.1.250' }); + if (isInventoryRequest(request)) return commandResult({ stdout: JSON.stringify(inventory) }); + return commandResult({ stdout: JSON.stringify([{ id: 'doctor-fixture@inline' }]) }); + }; + const doctor = () => runDoctor({ + commandRunner: runner, + endpointDirectory: fixture.endpointDirectory, + from: bundle, + home: fixture.home, + hosts: ['claude'], + }); + + const missing = hostReport(await doctor(), 'claude'); + expect(missing.bundle).toMatchObject({ comparison: { status: 'not-installed' }, state: 'registered' }); + expect(missing.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'AB7307', severity: 'info', target: 'claude' }), + ])); + + inventory = [{ enabled: true, id: 'doctor-fixture@doctor-fixture-marketplace', installPath: installed, scope: 'user', version: '1.2.3' }]; + const current = hostReport(await doctor(), 'claude'); + expect(current.bundle).toMatchObject({ + comparison: { + artifactContentHash: artifactHash, + installedContentHash: artifactHash, + installedPath: installed, + installedVersion: '1.2.3', + ownership: 'host', + status: 'current', + }, + state: 'registered', + }); + expect(current.diagnostics.filter((entry) => entry.severity !== 'info')).toEqual([]); + + await writeFile(join(installed, 'payload.txt'), 'stale\n'); + const staleHash = (await treeInventory(installed)).hash; + const stale = hostReport(await doctor(), 'claude'); + expect(stale.bundle).toMatchObject({ + comparison: { installedContentHash: staleHash, status: 'stale' }, + state: 'registered', + }); + const staleDiagnostic = stale.diagnostics.find((entry) => entry.code === 'AB7308'); + expect(staleDiagnostic).toMatchObject({ severity: 'warning', target: 'claude' }); + expect(staleDiagnostic?.message).toContain('stale (same version, different content)'); + expect(staleDiagnostic?.message).toContain(`content ${staleHash.slice(0, 12)}`); + expect(staleDiagnostic?.recovery).toContain('version-gated'); + + inventory = [{ enabled: true, id: 'doctor-fixture@doctor-fixture-marketplace', installPath: installed, scope: 'user', version: '1.0.0' }]; + const mismatch = hostReport(await doctor(), 'claude'); + expect(mismatch.bundle?.comparison).toMatchObject({ installedVersion: '1.0.0', status: 'version-mismatch' }); + expect(mismatch.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'AB7309', severity: 'warning', target: 'claude' }), + ])); + } finally { + await fixture.cleanup(); + } +}); + +it('compares the Codex cache copy against the artifact once plugin list --json names the install', async () => { + const fixture = await temporaryDoctor(); + try { + const bundle = await createBundle(fixture.root, 'codex'); + const codexHome = join(fixture.root, 'codex-home'); + const installed = join(codexHome, 'plugins', 'cache', 'doctor-fixture-marketplace', 'doctor-fixture', '1.2.3'); + await cp(bundle, installed, { recursive: true }); + await writeFile(join(installed, 'payload.txt'), 'stale\n'); + const artifactHash = (await treeInventory(bundle)).hash; + const staleHash = (await treeInventory(installed)).hash; + let installedRows: readonly unknown[] = []; + const runner: DoctorCommandRunner = async (request) => { + if (request.args[0] === '--version') return commandResult({ stdout: 'codex-cli 0.147.0' }); + if (isInventoryRequest(request)) return commandResult({ stdout: JSON.stringify({ available: [], installed: installedRows }) }); + return commandResult({ stdout: '' }); + }; + const doctor = () => runDoctor({ + commandRunner: runner, + endpointDirectory: fixture.endpointDirectory, + environment: { CODEX_HOME: codexHome }, + from: bundle, + home: fixture.home, + hosts: ['codex'], + }); + + const missing = hostReport(await doctor(), 'codex'); + expect(missing.bundle).toMatchObject({ comparison: { status: 'not-installed' }, state: 'missing' }); + expect(missing.diagnostics.some((entry) => entry.code === 'AB7313')).toBe(false); + expect(missing.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'AB7307', severity: 'info', target: 'codex' }), + ])); + + installedRows = [{ + enabled: true, + installed: true, + marketplaceName: 'doctor-fixture-marketplace', + name: 'doctor-fixture', + pluginId: 'doctor-fixture@doctor-fixture-marketplace', + version: '1.2.3', + }]; + const stale = hostReport(await doctor(), 'codex'); + expect(stale.bundle).toMatchObject({ + comparison: { + artifactContentHash: artifactHash, + installedContentHash: staleHash, + installedPath: installed, + ownership: 'host', + status: 'stale', + }, + state: 'installed', + }); + const staleDiagnostic = stale.diagnostics.find((entry) => entry.code === 'AB7308'); + expect(staleDiagnostic).toMatchObject({ severity: 'warning', target: 'codex' }); + expect(staleDiagnostic?.recovery).toContain('codex plugin remove'); + } finally { + await fixture.cleanup(); + } +}); + const serverSockets = new WeakMap>(); const listen = async (path: string, response?: unknown): Promise => { diff --git a/packages/agent-bundle/tests/host-install-proof.test.ts b/packages/agent-bundle/tests/host-install-proof.test.ts index d655d56ce..1dd92b333 100644 --- a/packages/agent-bundle/tests/host-install-proof.test.ts +++ b/packages/agent-bundle/tests/host-install-proof.test.ts @@ -305,7 +305,7 @@ claudePluginIt( expect(report, proofLabel).toEqual({ host: 'claude', - install: { state: 'installed', version: '1.0.0' }, + install: { sameVersionRebuild: 'replaced', state: 'installed', version: '1.0.0' }, inventory: { hooks: 1, mcpServers: 1, skills: 1 }, proofLevel: proofLabel, registration: { @@ -353,7 +353,7 @@ codexPluginIt( expect(report, proofLabel).toEqual({ host: 'codex', - install: { state: 'installed', version: '1.0.0' }, + install: { sameVersionRebuild: 'replaced', state: 'installed', version: '1.0.0' }, manifest: { interfaceCapabilities: ['hooks', 'mcp', 'skills'], interfaceFields: [...expectedCodexInterfaceFields], @@ -412,7 +412,7 @@ it('installs into an isolated Cursor home, validates schemas, and is idempotent' plugin: 'schema-valid', }, host: 'cursor', - install: { first: 'installed', second: 'already-installed', version: '1.0.0' }, + install: { first: 'installed', sameVersionRebuild: 'replaced', second: 'already-installed', version: '1.0.0' }, logo: { path: './assets/docs/media/logo.svg', resolvesInsideDeployTree: true, @@ -470,7 +470,7 @@ it( }, hooks: 'not-emitted', host: 'cursor', - install: { first: 'installed', second: 'already-installed', version: '1.0.0' }, + install: { first: 'installed', sameVersionRebuild: 'replaced', second: 'already-installed', version: '1.0.0' }, manifestMetadata: 'author/homepage/repository/license/keywords/extensions emitted from portable config', pluginVariables: { allowedLocations: 'args/env values/cwd only', diff --git a/packages/agent-bundle/tests/install-surface.test.ts b/packages/agent-bundle/tests/install-surface.test.ts index 27fec432a..2f1334459 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -1,8 +1,17 @@ +import { execFile as executeFile } from 'node:child_process'; +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { promisify } from 'node:util'; + import { expect, it } from '@rstest/core'; import { createDefaultRegistry } from '../src/adapters/registry.ts'; import type { TargetArtifactWrite } from '../src/adapters/types.ts'; import type { NormalizedPlugin } from '../src/core/types.ts'; +import { installReceiptFile, readInstallReceipt, treeInventory } from '../src/install/receipt.ts'; + +const execFile = promisify(executeFile); const modelFor = (target: string): NormalizedPlugin => ({ extensions: {}, @@ -104,3 +113,151 @@ it('documents every real host path from the composite profile', () => { expect(install).toContain('codex plugin add install-fixture@install-fixture-marketplace'); expect(install).toContain('node ./install.mjs'); }); + +it('documents the same-version reinstall recipe per host, including Claude\'s version-gated update', () => { + const claude = writesFor('claude').get('INSTALL.md') ?? ''; + expect(claude).toContain('Reinstall after a same-version rebuild'); + expect(claude).toContain('version-gated'); + expect(claude).toContain('claude plugin uninstall install-fixture@install-fixture-marketplace --scope user --keep-data'); + expect(claude).toContain('agent-bundle install claude --from ./'); + expect(claude).toContain('--replace'); + + const codex = writesFor('codex').get('INSTALL.md') ?? ''; + expect(codex).toContain('Reinstall after a same-version rebuild'); + expect(codex).toContain('codex plugin remove install-fixture@install-fixture-marketplace'); + expect(codex).toContain('--replace'); + + for (const target of ['cursor', 'portable', 'plugin']) { + const install = writesFor(target).get('INSTALL.md') ?? ''; + expect(install).toContain(installReceiptFile); + expect(install).toContain('--replace'); + expect(install).toContain('`state/`'); + } + expect(writesFor('cursor').get('INSTALL.md')).toContain('node ./install.mjs --replace'); + expect(writesFor('cursor').get('INSTALL.md')).toContain('content-hash comparison'); + + const installer = writesFor('cursor').get('install.mjs') ?? ''; + expect(installer).toContain("argument === '--replace' || argument === '--force'"); + expect(installer).toContain(`const receiptFile = ${JSON.stringify(installReceiptFile)};`); + expect(installer).toContain('Refusing foreign install'); + expect(installer).toContain('Refusing content collision'); + expect(installer).toContain('Refusing version collision'); +}); + +const run = async ( + installer: string, + args: readonly string[], + home: string, +): Promise<{ readonly code: number; readonly stderr: string; readonly stdout: string }> => { + try { + const result = await execFile(process.execPath, [installer, ...args], { + cwd: dirname(installer), + env: { ...process.env, HOME: home }, + }); + return { code: 0, stderr: result.stderr, stdout: result.stdout }; + } catch (error) { + const failure = error as { readonly code?: number; readonly stderr?: string; readonly stdout?: string }; + return { code: typeof failure.code === 'number' ? failure.code : 1, stderr: failure.stderr ?? '', stdout: failure.stdout ?? '' }; + } +}; + +const listFiles = async (root: string): Promise => + (await readdir(root, { recursive: true, withFileTypes: true })) + .filter((entry) => entry.isFile()) + .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 () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-install-mjs-')); + const bundle = join(root, 'bundle'); + const home = join(root, 'home'); + const destination = join(home, '.cursor', 'plugins', 'local', 'install-fixture'); + const installer = join(bundle, 'install.mjs'); + try { + const writes = writesFor('cursor'); + await mkdir(join(bundle, '.cursor-plugin'), { recursive: true }); + await mkdir(join(home, '.cursor'), { recursive: true }); + await Promise.all([ + writeFile(installer, writes.get('install.mjs') ?? ''), + writeFile(join(bundle, 'INSTALL.md'), writes.get('INSTALL.md') ?? ''), + writeFile(join(bundle, '.cursor-plugin', 'plugin.json'), JSON.stringify({ name: 'install-fixture', version: '1.2.3' })), + writeFile(join(bundle, 'payload.txt'), 'payload\n'), + writeFile(join(bundle, 'removed-later.txt'), 'old\n'), + ]); + + const help = await run(installer, ['--help'], home); + expect(help).toMatchObject({ code: 0 }); + expect(help.stdout).toContain('[--replace|--force]'); + const unknown = await run(installer, ['--bogus'], home); + expect(unknown.code).toBe(2); + expect(unknown.stderr).toContain('Unknown installer argument "--bogus"'); + + const first = await run(installer, [], home); + expect(first).toMatchObject({ code: 0, stderr: '' }); + expect(first.stdout).toContain('Installed install-fixture@1.2.3'); + const firstArtifact = await treeInventory(bundle); + // The emitted receipt is byte-compatible with the core reader. + expect(await readInstallReceipt(destination)).toMatchObject({ + contentHash: firstArtifact.hash, + files: firstArtifact.files, + host: 'cursor', + plugin: 'install-fixture', + version: '1.2.3', + }); + + const again = await run(installer, [], home); + expect(again).toMatchObject({ code: 0, stderr: '' }); + expect(again.stdout).toContain('Already installed install-fixture@1.2.3'); + const forcedNoop = await run(installer, ['--replace'], home); + expect(forcedNoop.stdout).toContain('Already installed install-fixture@1.2.3'); + + // Same version, different content: owned files replaced, runtime state and operator files preserved. + await mkdir(join(destination, 'state'), { recursive: true }); + await writeFile(join(destination, 'state', 'plugin.sqlite'), 'durable\n'); + await writeFile(join(bundle, 'payload.txt'), 'rebuilt\n'); + await rm(join(bundle, 'removed-later.txt')); + const replaced = await run(installer, [], home); + expect(replaced).toMatchObject({ code: 0, stderr: '' }); + expect(replaced.stdout).toContain('Replaced install-fixture@1.2.3'); + expect(replaced.stdout).toContain(`-> ${(await treeInventory(bundle)).hash.slice(0, 12)}`); + expect(await listFiles(destination)).toEqual([ + installReceiptFile, + '.cursor-plugin/plugin.json', + 'INSTALL.md', + 'install.mjs', + 'payload.txt', + 'state/plugin.sqlite', + ]); + expect(await readFile(join(destination, 'payload.txt'), 'utf8')).toBe('rebuilt\n'); + expect(await readFile(join(destination, 'state', 'plugin.sqlite'), 'utf8')).toBe('durable\n'); + + // Legacy pre-receipt copy with drift: refused with a hash comparison until --replace adopts it. + 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, ['--force'], 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' }); + + // Foreign directory under the plugin name: refused even with --replace. + await rm(destination, { force: true, recursive: true }); + await mkdir(join(destination, '.cursor-plugin'), { recursive: true }); + await writeFile(join(destination, '.cursor-plugin', 'plugin.json'), JSON.stringify({ name: 'install-fixture', version: '1.2.3' })); + await writeFile(join(destination, 'payload.txt'), 'someone else\n'); + const foreign = await run(installer, ['--replace'], home); + expect(foreign.code).toBe(1); + expect(foreign.stderr).toContain('Refusing foreign install'); + expect(foreign.stderr).toContain('same version, different content'); + expect(await readFile(join(destination, 'payload.txt'), 'utf8')).toBe('someone else\n'); + } finally { + await rm(root, { force: true, recursive: true }); + } +}, 60_000); diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index bcf2f4f74..82d98dafb 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -1,10 +1,15 @@ -import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { access, cp, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { expect, it } from '@rstest/core'; import { installBundle, type InstallCommandRunner } from '../src/install/install.ts'; +import { + installReceiptFile, + readInstallReceipt, + treeInventory, +} from '../src/install/receipt.ts'; import { DiagnosticError } from '../src/core/diagnostics.ts'; import { runCli } from '../src/cli.ts'; @@ -14,7 +19,9 @@ interface CommandCall { readonly cwd: string; } -const recordingRunner = (): { +const recordingRunner = ( + respond: (call: CommandCall) => string = () => '', +): { readonly calls: CommandCall[]; readonly runner: InstallCommandRunner; } => { @@ -23,13 +30,23 @@ const recordingRunner = (): { calls, runner: { run: async (command, args, options) => { - calls.push({ args: [...args], command, cwd: options.cwd }); - return { code: 0, stderr: '', stdout: '' }; + const call = { args: [...args], command, cwd: options.cwd }; + calls.push(call); + return { code: 0, stderr: '', stdout: respond(call) }; }, }, }; }; +const isInventoryCall = (call: CommandCall): boolean => + call.args.join(' ') === 'plugin list --json'; + +const listFiles = async (root: string): Promise => + (await readdir(root, { recursive: true, withFileTypes: true })) + .filter((entry) => entry.isFile()) + .map((entry) => join(entry.parentPath, entry.name).slice(root.length + 1)) + .sort((left, right) => left.localeCompare(right)); + const writeJson = async (path: string, value: unknown): Promise => { await mkdir(dirname(path), { recursive: true }); await writeFile(path, `${JSON.stringify(value)}\n`); @@ -84,6 +101,7 @@ const createHostBundle = async ( it.each([ { expected: [ + { args: ['plugin', 'list', '--json'], command: 'claude' }, { args: ['plugin', 'marketplace', 'add', resolve('/bundle')], command: 'claude' }, { args: ['plugin', 'install', 'install-fixture@install-fixture-marketplace', '--scope', 'project'], @@ -95,6 +113,7 @@ it.each([ }, { expected: [ + { args: ['plugin', 'list', '--json'], command: 'codex' }, { args: ['plugin', 'marketplace', 'add', resolve('/bundle')], command: 'codex' }, { args: ['plugin', 'add', 'install-fixture@install-fixture-marketplace'], command: 'codex' }, ], @@ -107,7 +126,12 @@ it.each([ try { const result = await installBundle({ commandRunner: runner, from: fixture.from, host, scope }); - expect(result).toMatchObject({ host, plugin: 'install-fixture', state: 'installed' }); + expect(result).toMatchObject({ + contentHash: (await treeInventory(fixture.bundleRoot)).hash, + host, + plugin: 'install-fixture', + state: 'installed', + }); expect(calls).toEqual(expected.map((call) => ({ ...call, args: call.args.map((arg) => arg === resolve('/bundle') ? fixture.bundleRoot : arg), cwd: fixture.bundleRoot }))); } finally { @@ -115,6 +139,111 @@ it.each([ } }); +const claudeInventory = (installPath: string, version = '1.2.3'): string => JSON.stringify([{ + enabled: true, + id: 'install-fixture@install-fixture-marketplace', + installPath, + scope: 'user', + version, +}]); + +const codexInventory = (version = '1.2.3'): string => JSON.stringify({ + available: [], + installed: [{ + enabled: true, + installed: true, + marketplaceName: 'install-fixture-marketplace', + name: 'install-fixture', + pluginId: 'install-fixture@install-fixture-marketplace', + version, + }], +}); + +it('replaces a stale same-version Claude install through uninstall + install and skips identical copies', async () => { + const fixture = await createHostBundle('claude'); + const installed = join(fixture.cleanupRoot, 'claude-cache', '1.2.3'); + await cp(fixture.bundleRoot, installed, { recursive: true }); + const { calls, runner } = recordingRunner((call) => isInventoryCall(call) ? claudeInventory(installed) : ''); + try { + const identical = await installBundle({ commandRunner: runner, from: fixture.from, host: 'claude', scope: 'user' }); + expect(identical).toMatchObject({ destination: installed, host: 'claude', state: 'already-installed' }); + expect(calls.map((call) => call.args.join(' '))).toEqual(['plugin list --json']); + + calls.length = 0; + await writeFile(join(installed, 'payload.txt'), 'stale\n'); + const replaced = await installBundle({ commandRunner: runner, from: fixture.from, host: 'claude', scope: 'user' }); + expect(replaced).toMatchObject({ + contentHash: (await treeInventory(fixture.bundleRoot)).hash, + previousContentHash: (await treeInventory(installed)).hash, + state: 'replaced', + }); + expect(calls.map((call) => call.args.join(' '))).toEqual([ + 'plugin list --json', + 'plugin uninstall install-fixture@install-fixture-marketplace --scope user --keep-data', + `plugin marketplace add ${fixture.bundleRoot}`, + 'plugin install install-fixture@install-fixture-marketplace --scope user', + ]); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + +it('honours --replace for Codex through remove + add and fails closed without a usable inventory', async () => { + const fixture = await createHostBundle('codex'); + const home = join(fixture.cleanupRoot, 'home'); + const codexHome = join(fixture.cleanupRoot, 'codex-home'); + // The host reports an older version installed from the pinned cache layout. + const installed = join(codexHome, 'plugins', 'cache', 'install-fixture-marketplace', 'install-fixture', '1.0.0'); + await cp(fixture.bundleRoot, installed, { recursive: true }); + const { calls, runner } = recordingRunner((call) => isInventoryCall(call) ? codexInventory('1.0.0') : ''); + const options = { + commandRunner: runner, + environment: { CODEX_HOME: codexHome }, + from: fixture.from, + home, + host: 'codex' as const, + scope: 'user' as const, + }; + try { + const plain = await installBundle(options); + expect(plain).toMatchObject({ host: 'codex', state: 'installed' }); + expect(calls.map((call) => call.args.join(' '))).toEqual([ + 'plugin list --json', + `plugin marketplace add ${fixture.bundleRoot}`, + 'plugin add install-fixture@install-fixture-marketplace', + ]); + + calls.length = 0; + const forced = await installBundle({ ...options, replace: true }); + expect(forced).toMatchObject({ + host: 'codex', + previousContentHash: (await treeInventory(installed)).hash, + state: 'replaced', + }); + expect(calls.map((call) => call.args.join(' '))).toEqual([ + 'plugin list --json', + 'plugin remove install-fixture@install-fixture-marketplace', + `plugin marketplace add ${fixture.bundleRoot}`, + 'plugin add install-fixture@install-fixture-marketplace', + ]); + + const unusable = recordingRunner(() => 'not json'); + const error = await installBundle({ + commandRunner: unusable.runner, + from: fixture.from, + host: 'codex', + replace: true, + scope: 'user', + }).catch((failure: unknown) => failure); + expect(error).toBeInstanceOf(DiagnosticError); + expect((error as DiagnosticError).diagnostics[0]).toMatchObject({ code: 'AB7004', target: 'codex' }); + expect((error as DiagnosticError).diagnostics[0]?.message).toContain('plugin list --json was unusable'); + expect(unusable.calls).toHaveLength(1); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + it('accepts an artifact root containing the requested host target', async () => { const fixture = await createHostBundle('claude', { artifactRoot: true }); const { calls, runner } = recordingRunner(); @@ -187,9 +316,109 @@ it('copies a Cursor bundle into a fake home and is idempotent', async () => { const first = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); const second = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); - expect(first).toMatchObject({ destination, host: 'cursor', state: 'installed' }); - expect(second).toMatchObject({ destination, host: 'cursor', state: 'already-installed' }); + const artifact = await treeInventory(fixture.bundleRoot); + expect(first).toMatchObject({ contentHash: artifact.hash, destination, host: 'cursor', state: 'installed' }); + expect(second).toMatchObject({ contentHash: artifact.hash, destination, host: 'cursor', state: 'already-installed' }); + expect(await readFile(join(destination, 'payload.txt'), 'utf8')).toBe('payload\n'); + expect(await readInstallReceipt(destination)).toMatchObject({ + contentHash: artifact.hash, + files: ['.cursor-plugin/plugin.json', 'payload.txt'], + format: 'agent-bundle-install-receipt/1', + host: 'cursor', + plugin: 'install-fixture', + version: '1.2.3', + }); + expect(await listFiles(destination)).toEqual([installReceiptFile, '.cursor-plugin/plugin.json', 'payload.txt']); + } finally { + await Promise.all([ + rm(fixture.cleanupRoot, { force: true, recursive: true }), + rm(home, { force: true, recursive: true }), + ]); + } +}); + +it('replaces a stale same-version receipt-managed Cursor install in place, touching owned files only', 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 { + await writeFile(join(fixture.bundleRoot, 'removed-later.txt'), 'old\n'); + const first = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); + const previousHash = first.contentHash; + // Runtime state beside the plugin is unowned and must survive replacement. + await mkdir(join(destination, 'state'), { recursive: true }); + await writeFile(join(destination, 'state', 'plugin.sqlite'), 'durable\n'); + await writeFile(join(destination, 'operator-note.txt'), 'keep me\n'); + + // Same version, different content: one owned file rewritten, one removed, one added. + await writeFile(join(fixture.bundleRoot, 'payload.txt'), 'rebuilt\n'); + await rm(join(fixture.bundleRoot, 'removed-later.txt')); + await mkdir(join(fixture.bundleRoot, 'skills', 'new'), { recursive: true }); + await writeFile(join(fixture.bundleRoot, 'skills', 'new', 'SKILL.md'), '# new\n'); + const artifact = await treeInventory(fixture.bundleRoot); + + const replaced = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); + expect(replaced).toMatchObject({ + contentHash: artifact.hash, + destination, + previousContentHash: previousHash, + state: 'replaced', + }); + expect(await listFiles(destination)).toEqual([ + installReceiptFile, + '.cursor-plugin/plugin.json', + 'operator-note.txt', + 'payload.txt', + 'skills/new/SKILL.md', + 'state/plugin.sqlite', + ]); + 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)).toMatchObject({ + contentHash: artifact.hash, + files: ['.cursor-plugin/plugin.json', 'payload.txt', 'skills/new/SKILL.md'], + }); + + const again = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); + expect(again).toMatchObject({ contentHash: artifact.hash, state: 'already-installed' }); + expect(again.previousContentHash).toBeUndefined(); + } finally { + await Promise.all([ + rm(fixture.cleanupRoot, { force: true, recursive: true }), + rm(home, { force: true, recursive: true }), + ]); + } +}); + +it('requires --replace for a legacy pre-receipt Cursor copy and then adopts it', 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 { + await writeFile(join(fixture.bundleRoot, 'INSTALL.md'), '# install\n'); + await writeFile(join(fixture.bundleRoot, 'install.mjs'), '// installer\n'); + await cp(fixture.bundleRoot, destination, { recursive: true }); + await writeFile(join(destination, 'payload.txt'), 'stale\n'); + const legacyHash = (await treeInventory(destination)).hash; + const artifact = await treeInventory(fixture.bundleRoot); + + 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 adopted = await installBundle({ from: fixture.from, home, host: 'cursor', replace: true, scope: 'user' }); + expect(adopted).toMatchObject({ contentHash: artifact.hash, previousContentHash: legacyHash, state: 'replaced' }); expect(await readFile(join(destination, 'payload.txt'), 'utf8')).toBe('payload\n'); + expect(await readInstallReceipt(destination)).toMatchObject({ contentHash: artifact.hash, plugin: 'install-fixture' }); } finally { await Promise.all([ rm(fixture.cleanupRoot, { force: true, recursive: true }), @@ -222,35 +451,54 @@ it('fails closed when Cursor is not detected in the selected home', async () => } }); -it('refuses Cursor version and content collisions', async () => { +it('refuses foreign Cursor directories even with --replace and gates version collisions behind it', 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 { + // A hand-made directory under the plugin name: manifest present, no receipt, no install surface. + await writeJson(join(destination, '.cursor-plugin/plugin.json'), { name: 'install-fixture', version: '1.2.3' }); + await writeFile(join(destination, 'payload.txt'), 'someone else\n'); + const foreignHash = (await treeInventory(destination)).hash; + for (const replace of [false, true]) { + const error = await installBundle({ from: fixture.from, home, host: 'cursor', replace, scope: 'user' }) + .catch((failure: unknown) => failure); + expect(error).toBeInstanceOf(DiagnosticError); + expect((error as DiagnosticError).diagnostics[0]).toMatchObject({ code: 'AB7005', target: 'cursor' }); + const message = (error as DiagnosticError).diagnostics[0]?.message ?? ''; + expect(message).toContain('Refusing foreign install'); + expect(message).toContain(`content ${foreignHash.slice(0, 12)}`); + expect(message).toContain('same version, different content'); + } + expect(await readFile(join(destination, 'payload.txt'), 'utf8')).toBe('someone else\n'); + + // A different plugin's receipt-managed install at this path is foreign as well. + await rm(destination, { force: true, recursive: true }); await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); - await writeFile(join(destination, 'payload.txt'), 'changed\n'); - const contentError = await installBundle({ - from: fixture.from, - home, - host: 'cursor', - scope: 'user', - }).catch((failure: unknown) => failure); - expect(contentError).toBeInstanceOf(DiagnosticError); - expect((contentError as DiagnosticError).diagnostics).toMatchObject([{ code: 'AB7005' }]); + await writeJson(join(destination, '.cursor-plugin/plugin.json'), { name: 'other-plugin', version: '1.2.3' }); + const receipt = JSON.parse(await readFile(join(destination, installReceiptFile), 'utf8')) as Record; + await writeJson(join(destination, installReceiptFile), { ...receipt, plugin: 'other-plugin' }); + const otherError = await installBundle({ from: fixture.from, home, host: 'cursor', replace: true, scope: 'user' }) + .catch((failure: unknown) => failure); + expect((otherError as DiagnosticError).diagnostics[0]?.message).toContain('Refusing foreign install'); + expect((otherError as DiagnosticError).diagnostics[0]?.message).toContain('installed other-plugin@1.2.3'); - await writeJson(join(destination, '.cursor-plugin/plugin.json'), { - name: 'install-fixture', - version: '9.0.0', - }); - const versionError = await installBundle({ - from: fixture.from, - home, - host: 'cursor', - scope: 'user', - }).catch((failure: unknown) => failure); + // A receipt-managed install of this plugin at another version needs --replace. + await rm(destination, { force: true, recursive: true }); + await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); + await writeJson(join(destination, '.cursor-plugin/plugin.json'), { name: 'install-fixture', version: '9.0.0' }); + const versionError = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }) + .catch((failure: unknown) => failure); expect(versionError).toBeInstanceOf(DiagnosticError); - expect((versionError as DiagnosticError).diagnostics[0]?.message).toContain('version collision'); + expect((versionError as DiagnosticError).diagnostics[0]?.message).toContain('Refusing version collision'); + expect((versionError as DiagnosticError).diagnostics[0]?.message).toContain('installed install-fixture@9.0.0'); + expect((versionError as DiagnosticError).diagnostics[0]?.message).toContain('(different version)'); + const forced = await installBundle({ from: fixture.from, home, host: 'cursor', replace: true, scope: 'user' }); + expect(forced).toMatchObject({ state: 'replaced', version: '1.2.3' }); + expect(JSON.parse(await readFile(join(destination, '.cursor-plugin/plugin.json'), 'utf8'))).toMatchObject({ + version: '1.2.3', + }); } finally { await Promise.all([ rm(fixture.cleanupRoot, { force: true, recursive: true }), @@ -343,7 +591,7 @@ it('dispatches the public CLI install command to the native installer', async () Object.defineProperty(globalThis, '__AGENT_BUNDLE_VERSION__', { configurable: true, value: 'test' }); const code = await runCli( - ['install', 'claude', '--from', '/tmp/example bundle', '--scope', 'project', '--json'], + ['install', 'claude', '--from', '/tmp/example bundle', '--scope', 'project', '--force', '--json'], { stderr: { write: (chunk: string) => stderr.push(chunk) }, stdout: { write: (chunk: string) => stdout.push(chunk) }, @@ -368,6 +616,7 @@ it('dispatches the public CLI install command to the native installer', async () expect(calls).toEqual([{ from: '/tmp/example bundle', host: 'claude', + replace: true, scope: 'project', }]); expect(JSON.parse(stdout.join(''))).toMatchObject({ diff --git a/packages/agent-bundle/tests/installer-entry.test.ts b/packages/agent-bundle/tests/installer-entry.test.ts index a45bcbd60..7f9097677 100644 --- a/packages/agent-bundle/tests/installer-entry.test.ts +++ b/packages/agent-bundle/tests/installer-entry.test.ts @@ -7,6 +7,7 @@ import { promisify } from 'node:util'; import { afterEach, expect, it } from '@rstest/core'; import { build } from '../src/api.ts'; +import { installReceiptFile } from '../src/install/receipt.ts'; const execFile = promisify(executeFile); const roots: string[] = []; @@ -82,7 +83,7 @@ it('builds a package-relative installer with fallback naming and built-host argv const help = await run(installer, [], { cwd: tmpdir() }); expect(help).toMatchObject({ code: 0, stderr: '' }); - expect(help.stdout).toContain('install [--scope ] [--json]'); + expect(help.stdout).toContain('install [--scope ] [--replace|--force] [--json]'); expect(help.stdout).toContain('cursor'); expect(help.stdout).not.toContain('claude'); @@ -107,13 +108,37 @@ it('builds a package-relative installer with fallback naming and built-host argv env: { ...process.env, HOME: home }, }); expect(installed).toMatchObject({ code: 0, stderr: '' }); - expect(JSON.parse(installed.stdout)).toMatchObject({ + const installedDocument = JSON.parse(installed.stdout) as { readonly contentHash?: string }; + expect(installedDocument).toMatchObject({ + contentHash: expect.stringMatching(/^[0-9a-f]{64}$/u), host: 'cursor', plugin: 'installer-fixture', state: 'installed', version: '1.2.3', }); - await expect(stat(join(home, '.cursor', 'plugins', 'local', 'installer-fixture'))).resolves.toBeDefined(); + const destination = join(home, '.cursor', 'plugins', 'local', 'installer-fixture'); + await expect(stat(destination)).resolves.toBeDefined(); + await expect(stat(join(destination, installReceiptFile))).resolves.toBeDefined(); + + // Both replace spellings parse; an identical artifact stays a no-op even when forced. + for (const flag of ['--replace', '--force']) { + const noop = await run(installer, ['install', 'cursor', flag, '--json'], { + cwd: tmpdir(), + env: { ...process.env, HOME: home }, + }); + expect(noop, flag).toMatchObject({ code: 0, stderr: '' }); + expect(JSON.parse(noop.stdout), flag).toMatchObject({ contentHash: installedDocument.contentHash, state: 'already-installed' }); + } + + // Same-version content drift of the receipt-managed copy is replaced without a flag. + await writeFile(join(destination, 'INSTALL.md'), '# stale\n'); + const replaced = await run(installer, ['install', 'cursor'], { cwd: tmpdir(), env: { ...process.env, HOME: home } }); + expect(replaced).toMatchObject({ code: 0, stderr: '' }); + expect(replaced.stdout).toMatch(/^Replaced installer-fixture@1\.2\.3 for cursor at .* \(content [0-9a-f]{12} -> [0-9a-f]{12}\)\n$/u); + + const unknown = await run(installer, ['install', 'cursor', '--overwrite'], { cwd: tmpdir() }); + expect(unknown.code).toBe(1); + expect(unknown.stderr).toContain('Unknown installer argument "--overwrite"'); }, 120_000); it('chooses an unused installer name when both primary candidates are bins', async () => { @@ -142,7 +167,7 @@ it('handles installer help when the project path contains a percent sign', async const help = await run(join(root, 'dist', 'bin', 'installer-fixture-install.js'), ['--help'], { cwd: tmpdir() }); expect(help).toMatchObject({ code: 0, stderr: '' }); - expect(help.stdout).toContain('install [--scope ] [--json]'); + expect(help.stdout).toContain('install [--scope ] [--replace|--force] [--json]'); }, 120_000); it('uses the plugin name when free and skips portable-only artifacts', async () => { 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 9bf77628e..e8f47aff0 100644 --- a/packages/agent-bundle/tests/packed-host-install-proof.test.ts +++ b/packages/agent-bundle/tests/packed-host-install-proof.test.ts @@ -161,7 +161,7 @@ claudePluginIt( expect(report, proofLabel).toEqual({ host: 'claude', - install: { state: 'installed', version: '1.0.0' }, + install: { sameVersionRebuild: 'replaced', state: 'installed', version: '1.0.0' }, inventory: { hooks: 1, mcpServers: 1, skills: 1 }, proofLevel: proofLabel, registration: { @@ -193,7 +193,7 @@ codexPluginIt( expect(report, proofLabel).toEqual({ host: 'codex', - install: { state: 'installed', version: '1.0.0' }, + install: { sameVersionRebuild: 'replaced', state: 'installed', version: '1.0.0' }, manifest: { interfaceCapabilities: ['hooks', 'mcp', 'skills'], interfaceFields: [...expectedCodexInterfaceFields], @@ -236,7 +236,7 @@ it('installs the packed tarball into an isolated Cursor home, validates schemas, plugin: 'schema-valid', }, host: 'cursor', - install: { first: 'installed', second: 'already-installed', version: '1.0.0' }, + install: { first: 'installed', sameVersionRebuild: 'replaced', second: 'already-installed', version: '1.0.0' }, logo: { path: './assets/docs/media/logo.svg', resolvesInsideDeployTree: true, diff --git a/packages/agent-bundle/tests/support/host-install.ts b/packages/agent-bundle/tests/support/host-install.ts index be7f976ec..d18460f44 100644 --- a/packages/agent-bundle/tests/support/host-install.ts +++ b/packages/agent-bundle/tests/support/host-install.ts @@ -120,10 +120,12 @@ interface CommandResult { interface InstallResult { readonly bundleRoot?: unknown; + readonly contentHash?: unknown; readonly destination?: unknown; readonly host?: unknown; readonly marketplace?: unknown; readonly plugin?: unknown; + readonly previousContentHash?: unknown; readonly state?: unknown; readonly version?: unknown; } @@ -192,9 +194,16 @@ interface HostInstallProofOptions { readonly installCommand?: HostInstallCommand; } +/** The same-version rebuild round trip every host proof performs after its first install. */ +export type SameVersionRebuildProof = 'replaced'; + export interface ClaudeHostInstallReport { readonly host: 'claude'; - readonly install: { readonly state: 'installed'; readonly version: '1.0.0' }; + readonly install: { + readonly sameVersionRebuild: SameVersionRebuildProof; + readonly state: 'installed'; + readonly version: '1.0.0'; + }; readonly inventory: { readonly hooks: 1; readonly mcpServers: 1; readonly skills: 1 }; readonly proofLevel: string; readonly registration: { @@ -211,7 +220,11 @@ export interface ClaudeHostInstallReport { export interface CodexHostInstallReport { readonly host: 'codex'; - readonly install: { readonly state: 'installed'; readonly version: '1.0.0' }; + readonly install: { + readonly sameVersionRebuild: SameVersionRebuildProof; + readonly state: 'installed'; + readonly version: '1.0.0'; + }; readonly manifest: { readonly interfaceCapabilities: readonly string[]; /** Sorted installed `interface` keys; every one is in the adapter's declared `codexInterfaceFields`. */ @@ -246,6 +259,7 @@ export interface CursorHostInstallReport { readonly host: 'cursor'; readonly install: { readonly first: 'installed'; + readonly sameVersionRebuild: SameVersionRebuildProof; readonly second: 'already-installed'; readonly version: '1.0.0'; }; @@ -276,6 +290,7 @@ export interface PortableHostInstallReport { readonly host: 'cursor'; readonly install: { readonly first: 'installed'; + readonly sameVersionRebuild: SameVersionRebuildProof; readonly second: 'already-installed'; readonly version: '1.0.0'; }; @@ -452,17 +467,55 @@ const stringEnvironment = ( const assertInstallResult = ( document: InstallResult, host: 'claude' | 'codex' | 'cursor', - state: 'already-installed' | 'installed', + state: 'already-installed' | 'installed' | 'replaced', ): void => { assertProof(document.host === host, `${host} install result did not identify the host.`); assertProof(document.plugin === plugin, `${host} install result did not identify ${plugin}.`); assertProof(document.version === version, `${host} install result did not identify version ${version}.`); assertProof(document.state === state, `${host} install result state was not ${state}.`); + assertProof( + typeof document.contentHash === 'string' && /^[0-9a-f]{64}$/u.test(document.contentHash), + `${host} install result carried no artifact content hash.`, + ); if (host !== 'cursor') { assertProof(document.marketplace === marketplace, `${host} install result did not identify ${marketplace}.`); } }; +const sameVersionRebuildMarker = 'REBUILD-SAME-VERSION.md'; + +/** + * Same-version rebuild against the real host: one file is added to the built + * bundle (content changes, `version` does not), the installer runs again and + * must report `replaced`, the host's cached copy must carry the new file, and + * a third run must be the `already-installed` no-op. The marker is removed + * afterwards so the shared built fixture is unchanged for later proofs. + */ +const proveSameVersionRebuild = async (options: { + readonly bundle: string; + readonly host: 'claude' | 'codex' | 'cursor'; + readonly install: () => Promise; + readonly installedRoot: string; +}): Promise => { + const marker = join(options.bundle, sameVersionRebuildMarker); + await writeFile(marker, '# same-version rebuild\n'); + try { + const replaced = await options.install(); + assertInstallResult(replaced, options.host, 'replaced'); + assertProof( + typeof replaced.previousContentHash === 'string' && replaced.previousContentHash !== replaced.contentHash, + `${options.host} replace did not report the superseded content hash.`, + ); + await access(join(options.installedRoot, sameVersionRebuildMarker)).catch(() => + fail(`${options.host} installed copy was not refreshed by the same-version rebuild.`)); + const again = await options.install(); + assertInstallResult(again, options.host, 'already-installed'); + return 'replaced'; + } finally { + await rm(marker, { force: true }); + } +}; + /** * Builds one fixture project with the workspace's source-built CLI. This proves * real-host acceptance of that built bundle; packed provenance remains the @@ -860,9 +913,19 @@ export const runClaudeHostInstallProof = async ( const skillPath = join(expectedInstallPath, 'skills', 'probe', 'SKILL.md'); await access(skillPath).catch(() => fail('Claude cache did not contain skills/probe/SKILL.md.')); + const sameVersionRebuild = await proveSameVersionRebuild({ + bundle: fixture.bundles.claude, + host: 'claude', + install: async () => { + const result = await runInstallCommand(fixture, 'claude', fixture.bundles.claude, { ...options, environment }); + assertProof(result.exitCode === 0, `Claude same-version reinstall failed: ${commandDetail(result)}`); + return parseJson(result.stdout, 'Claude reinstall'); + }, + installedRoot: expectedInstallPath, + }); return Object.freeze({ host: 'claude', - install: Object.freeze({ state: 'installed', version }), + install: Object.freeze({ sameVersionRebuild, state: 'installed', version }), inventory: Object.freeze({ hooks: 1, mcpServers: 1, skills: 1 }), proofLevel, registration: Object.freeze({ @@ -976,10 +1039,20 @@ export const runCodexHostInstallProof = async ( `Codex installed plugin manifest interface did not advertise ${capability}.`, ); } + const sameVersionRebuild = await proveSameVersionRebuild({ + bundle: fixture.bundles.codex, + host: 'codex', + install: async () => { + const result = await runInstallCommand(fixture, 'codex', fixture.bundles.codex, { ...options, environment }); + assertProof(result.exitCode === 0, `Codex same-version reinstall failed: ${commandDetail(result)}`); + return parseJson(result.stdout, 'Codex reinstall'); + }, + installedRoot: cachePath, + }); return Object.freeze({ host: 'codex', - install: Object.freeze({ state: 'installed', version }), + install: Object.freeze({ sameVersionRebuild, state: 'installed', version }), manifest: Object.freeze({ interfaceCapabilities: Object.freeze(interfaceCapabilities), interfaceFields: Object.freeze(interfaceFields), @@ -1108,6 +1181,12 @@ export const runCursorHostInstallProof = async ( const second = await install(); assertInstallResult(second, 'cursor', 'already-installed'); assertProof(second.destination === destination, 'Cursor idempotent install reported a different destination.'); + const sameVersionRebuild = await proveSameVersionRebuild({ + bundle: fixture.bundles.cursor, + host: 'cursor', + install, + installedRoot: destination, + }); return Object.freeze({ destination: normalizedRelative(home, destination), @@ -1119,6 +1198,7 @@ export const runCursorHostInstallProof = async ( host: 'cursor', install: Object.freeze({ first: 'installed', + sameVersionRebuild, second: 'already-installed', version, }), @@ -1150,7 +1230,7 @@ export const runPortableHostInstallProof = async ( await mkdir(join(home, '.cursor'), { recursive: true }); const environment = isolatedEnvironment(options.environment, { HOME: home }); const installer = join(fixture.portableBundle, 'install.mjs'); - const install = async (state: 'Already installed' | 'Installed'): Promise => { + const install = async (state: 'Already installed' | 'Installed' | 'Replaced'): Promise => { const result = await run(process.execPath, [installer], { cwd: fixture.portableBundle, environment, @@ -1309,6 +1389,20 @@ export const runPortableHostInstallProof = async ( await install('Already installed'); + // Same-version rebuild through the emitted install.mjs: owned files replaced in place, then a no-op. + const marker = join(fixture.portableBundle, sameVersionRebuildMarker); + await writeFile(marker, '# same-version rebuild\n'); + let sameVersionRebuild: SameVersionRebuildProof; + try { + await install('Replaced'); + await access(join(destination, sameVersionRebuildMarker)).catch(() => + fail('Portable emitted installer did not refresh the installed copy for the same-version rebuild.')); + await install('Already installed'); + sameVersionRebuild = 'replaced'; + } finally { + await rm(marker, { force: true }); + } + return Object.freeze({ contract: 'agent-plugins-1.0.0 byte lane clean (AB6035–AB6037)', destination: normalizedRelative(home, destination), @@ -1320,6 +1414,7 @@ export const runPortableHostInstallProof = async ( host: 'cursor', install: Object.freeze({ first: 'installed', + sameVersionRebuild, second: 'already-installed', version, }), From 5a9ee1a53ddeb60f54f990c302350801da5cca62 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 09:28:13 +0000 Subject: [PATCH 02/22] fix(install): harden replace policy per review (#400) - Refuse to overwrite an existing unowned file when a rebuilt artifact introduces a path it occupies; abort before any change and name the paths. - Validate receipt file lists as strict POSIX-relative paths (no backslashes, no ..///./empty segments, no drive letters) before they can drive deletions. - Treat a matching but malformed `plugin list --json` row as an unusable inventory so `--replace` fails closed instead of skipping the uninstall. - `--replace` on a byte-identical legacy copy adopts it by writing the receipt (`adopted`) instead of silently doing nothing. The emitted standalone install.mjs mirrors every rule. --- docs/diagnostics.md | 10 ++- packages/agent-bundle/README.md | 7 +- packages/agent-bundle/src/cli.ts | 2 + packages/agent-bundle/src/install-entry.ts | 2 + packages/agent-bundle/src/install/install.ts | 28 ++++-- packages/agent-bundle/src/install/receipt.ts | 51 +++++++++-- packages/agent-bundle/src/install/surface.ts | 45 +++++++--- .../tests/install-surface.test.ts | 29 +++++++ packages/agent-bundle/tests/install.test.ts | 86 ++++++++++++++++--- 9 files changed, 215 insertions(+), 45 deletions(-) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 0aaa28f2e..ab24850a2 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -702,7 +702,8 @@ this plugin's name — a copy installed before receipts existed), or **foreign** | Installed copy | `install` | `install --replace` (alias `--force`) | Doctor | | --- | --- | --- | --- | -| Identical content | `already-installed` no-op | `already-installed` no-op | `current` | +| 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 | replaced and adopted (receipt written) | `stale` — `AB7308` warning, recovery names `--replace` | @@ -716,7 +717,12 @@ Cursor replacement is in place and touches owned files only: stale owned files are removed and their empty directories pruned, staged files are renamed over their predecessors, and the receipt lands last. Entries the installer does not own — notably workspace-durable `state/` stores — are never removed or -rewritten. +rewritten; when a rebuilt artifact introduces a path that an existing unowned +entry already occupies, replacement aborts before any change (`AB7004`, +"Refusing to overwrite unowned files") and names the colliding paths. Receipt +file lists are validated as strict POSIX-relative paths (no backslashes, no +`..`/`.`/empty segments, no drive letters) before they can drive a deletion; a +receipt that fails validation reads as absent. | Code | Severity | Trigger | Recovery | | --- | --- | --- | --- | diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index d6172344b..e87593768 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -193,7 +193,9 @@ 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. Claude replacement runs + such as workspace-durable `state/` stores 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` before `marketplace add` + `install`, because Claude's `plugin update` is version-gated and a plain reinstall reports "already installed" while the @@ -203,7 +205,8 @@ every host treats it differently. `agent-bundle install` and the emitted 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). Adoption writes the receipt, so - later same-version rebuilds replace automatically. + later same-version rebuilds replace automatically; a byte-identical legacy + copy under `--replace` reports `adopted` and changes no plugin file. - **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 diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts index e74738f1f..93d320cba 100644 --- a/packages/agent-bundle/src/cli.ts +++ b/packages/agent-bundle/src/cli.ts @@ -279,6 +279,8 @@ const writeHumanPrepack = (output: Output, result: Awaited { switch (state) { + case 'adopted': + return 'Adopted'; case 'already-installed': return 'Already installed'; case 'installed': diff --git a/packages/agent-bundle/src/install-entry.ts b/packages/agent-bundle/src/install-entry.ts index 8f854ea32..157e80982 100644 --- a/packages/agent-bundle/src/install-entry.ts +++ b/packages/agent-bundle/src/install-entry.ts @@ -38,6 +38,8 @@ const diagnosticsFor = (error: unknown): readonly Diagnostic[] => const stateLabel = (state: InstallResult['state']): string => { switch (state) { + case 'adopted': + return 'Adopted'; case 'already-installed': return 'Already installed'; case 'installed': diff --git a/packages/agent-bundle/src/install/install.ts b/packages/agent-bundle/src/install/install.ts index 768145460..5ddf593bf 100644 --- a/packages/agent-bundle/src/install/install.ts +++ b/packages/agent-bundle/src/install/install.ts @@ -12,10 +12,12 @@ import { runPromise } from '../effect/boundary.ts'; import { liftPromise } from '../effect/lift.ts'; import { compareInstalledTree, + createInstallReceipt, describeContentComparison, replaceInstalledTree, stageArtifact, treeInventory, + writeInstallReceipt, type InstalledManifestIdentity, type InstalledTreeComparison, type TreeInventory, @@ -23,7 +25,11 @@ import { export type InstallHost = 'claude' | 'codex' | 'cursor'; export type InstallScope = 'local' | 'project' | 'user'; -export type InstallResultState = 'already-installed' | 'installed' | 'replaced'; +/** + * `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'; export interface InstallCommandResult { readonly code: number; @@ -278,8 +284,10 @@ export const parsePublicHostInventory = ( isRecord(candidate) && candidate['id'] === id && (options.scope === undefined || candidate['scope'] === options.scope)); - if (row === undefined || !isRecord(row) || typeof row['installPath'] !== 'string') { - return { status: 'available' }; + if (row === undefined) return { status: 'available' }; + // A matching row we cannot read is not "not installed": replacing on it would skip the uninstall. + if (!isRecord(row) || typeof row['installPath'] !== 'string') { + return { detail: `claude plugin list --json row for ${id} carries no installPath`, status: 'unavailable' }; } return { entry: { @@ -294,8 +302,9 @@ export const parsePublicHostInventory = ( } const row = document['installed'].find((candidate) => isRecord(candidate) && candidate['pluginId'] === id && candidate['installed'] !== false); - if (row === undefined || !isRecord(row) || typeof row['version'] !== 'string') { - return { status: 'available' }; + if (row === undefined) return { status: 'available' }; + if (!isRecord(row) || typeof row['version'] !== 'string') { + return { detail: `codex plugin list --json row for ${id} carries no version`, status: 'unavailable' }; } return { entry: { @@ -513,6 +522,15 @@ const installCursor = async ( version: identity.version, }); if (comparison.status === 'current') { + if (comparison.ownership === 'legacy' && options.replace === true) { + await writeInstallReceipt(destination, createInstallReceipt({ + host: 'cursor', + inventory: artifact, + plugin: identity.plugin, + version: identity.version, + })); + return { ...base, contentHash: artifact.hash, state: 'adopted' }; + } return { ...base, contentHash: artifact.hash, state: 'already-installed' }; } const replaceable = comparison.status === 'stale' && comparison.ownership === 'receipt' diff --git a/packages/agent-bundle/src/install/receipt.ts b/packages/agent-bundle/src/install/receipt.ts index b8deb4d60..6dc09204d 100644 --- a/packages/agent-bundle/src/install/receipt.ts +++ b/packages/agent-bundle/src/install/receipt.ts @@ -15,7 +15,7 @@ import { basename, dirname, join } from 'node:path'; import { stableJson } from '../core/digest.ts'; import { isErrno } from '../core/errors.ts'; -import { exists, safeArtifactPath } from '../core/paths.ts'; +import { exists } from '../core/paths.ts'; /** * Host-agnostic install ownership core shared by `agent-bundle install`, @@ -144,12 +144,22 @@ export const hashOwnedFiles = async (root: string, files: readonly string[]): Pr return hash.digest('hex'); }; +/** + * Receipt paths drive deletions, so they must be exactly what `treeInventory` + * emits: POSIX-relative, no backslashes (a Windows `..\outside` must not slip + * past POSIX normalization), no empty, `.`, or `..` segments, no drive letter. + */ +export const isReceiptPath = (value: unknown): value is string => + typeof value === 'string' && + value.length > 0 && + value !== installReceiptFile && + !value.includes('\\') && + !value.startsWith('/') && + !/^[a-z]:/iu.test(value) && + value.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..'); + const isReceiptFileList = (value: unknown): value is readonly string[] => - Array.isArray(value) && - value.every((entry) => - typeof entry === 'string' && - safeArtifactPath(entry) && - entry !== installReceiptFile); + Array.isArray(value) && value.every(isReceiptPath); /** Reads the receipt at a plugin root; malformed or unsafe receipts read as absent. */ export const readInstallReceipt = async (destination: string): Promise => { @@ -202,6 +212,17 @@ export const createInstallReceipt = (options: { const receiptDocument = (receipt: InstallReceipt): string => `${stableJson(receipt)}\n`; +/** Lands a receipt at a plugin root atomically (sibling temp file + rename). */ +export const writeInstallReceipt = async (destination: string, receipt: InstallReceipt): Promise => { + const temporary = join(destination, `${installReceiptFile}.${process.pid}.tmp`); + await writeFile(temporary, receiptDocument(receipt), 'utf8'); + try { + await rename(temporary, join(destination, installReceiptFile)); + } finally { + await rm(temporary, { force: true }); + } +}; + const hasInstallSurfaceMarkers = async (destination: string): Promise => { for (const marker of installSurfaceMarkerFiles) { if (!await exists(join(destination, marker))) return false; @@ -353,7 +374,8 @@ const previouslyOwnedFiles = async ( * Replaces an agent-bundle-owned install in place: stale owned files leave * first (their now-empty directories are pruned), every staged file then * moves over its predecessor with an atomic rename, and the receipt lands - * last as the commit marker. Unowned entries are never touched. + * last as the commit marker. Unowned entries are never touched; an incoming + * file that would land on an existing unowned entry aborts before any change. */ export const replaceInstalledTree = async (options: { readonly comparison: InstalledTreeComparison; @@ -364,8 +386,19 @@ export const replaceInstalledTree = async (options: { throw new Error(`Refusing to replace foreign install at ${options.destination}.`); } const incoming = new Set(options.staged.inventory.files); - const stale = (await previouslyOwnedFiles(options.destination, options.comparison)) - .filter((file) => !incoming.has(file)); + const previouslyOwned = await previouslyOwnedFiles(options.destination, options.comparison); + const owned = new Set(previouslyOwned); + const collisions: string[] = []; + for (const file of options.staged.inventory.files) { + if (!owned.has(file) && await exists(join(options.destination, file))) collisions.push(file); + } + if (collisions.length > 0) { + throw new Error( + `Refusing to overwrite unowned files at ${options.destination}: ${collisions.join(', ')}. ` + + 'Move them aside manually before replacing.', + ); + } + const stale = previouslyOwned.filter((file) => !incoming.has(file)); for (const file of stale) { await rm(join(options.destination, file), { force: true }); } diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index 5cc2fc18d..22566dc32 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -176,7 +176,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { "import { createHash } from 'node:crypto';", "import { cp, lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, rmdir, writeFile } from 'node:fs/promises';", "import { homedir } from 'node:os';", - "import { basename, dirname, isAbsolute, join, resolve } from 'node:path';", + "import { basename, dirname, join, resolve } from 'node:path';", "import { fileURLToPath } from 'node:url';", '', `const pluginName = ${name};`, @@ -251,8 +251,10 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { " return hash.digest('hex');", '};', '', - "const safeRelative = (value) => typeof value === 'string' && value.length > 0 && !isAbsolute(value) &&", - " value !== receiptFile && !value.startsWith('./') && value.split('/').every((segment) => segment !== '' && segment !== '..');", + '// Receipt paths drive deletions: POSIX-relative only, no backslashes, no empty/./.. segments, no drive letter.', + "const safeRelative = (value) => typeof value === 'string' && value.length > 0 && value !== receiptFile &&", + " !value.includes('\\\\') && !value.startsWith('/') && !/^[a-z]:/i.test(value) &&", + " value.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..');", '', 'const readReceipt = async (root) => {', ' let value;', @@ -281,6 +283,16 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' return true;', '};', '', + 'const receiptFor = (tree) => JSON.stringify({', + ' contentHash: tree.hash,', + ' files: tree.files,', + ' format: receiptFormat,', + " host: 'cursor',", + ' installedAt: new Date().toISOString(),', + ' plugin: pluginName,', + ' version: pluginVersion,', + "}, null, 2) + '\\n';", + '', '// Staged sibling copy on the destination filesystem so every later rename is atomic.', 'const stage = async () => {', ' const parent = await mkdtemp(join(installRoot, `.${basename(destination)}.stage-`));', @@ -289,16 +301,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' await cp(source, root, { errorOnExist: true, force: false, recursive: true, verbatimSymlinks: true });', ' await rm(join(root, receiptFile), { force: true });', ' const staged = await inventory(root);', - ' const receipt = {', - ' contentHash: staged.hash,', - ' files: staged.files,', - ' format: receiptFormat,', - " host: 'cursor',", - ' installedAt: new Date().toISOString(),', - ' plugin: pluginName,', - ' version: pluginVersion,', - ' };', - " await writeFile(join(root, receiptFile), `${JSON.stringify(receipt, null, 2)}\\n`, 'utf8');", + " await writeFile(join(root, receiptFile), receiptFor(staged), 'utf8');", ' return { inventory: staged, parent, root };', ' } catch (error) {', ' await rm(parent, { force: true, recursive: true });', @@ -355,6 +358,14 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { "const installedVersion = manifest?.version ?? (ownership === 'receipt' ? receipt.version : undefined);", "const installedName = manifest?.name ?? (ownership === 'receipt' ? receipt.plugin : pluginName);", 'if (installedHash === artifact.hash) {', + " if (ownership === 'legacy' && replace) {", + ' // Byte-identical pre-receipt copy: adoption only writes the receipt.', + ' const temporary = join(destination, `${receiptFile}.${process.pid}.tmp`);', + " await writeFile(temporary, receiptFor(artifact), 'utf8');", + ' await rename(temporary, join(destination, receiptFile));', + ' console.log(`Adopted ${pluginName}@${pluginVersion} at ${destination} (content ${short(artifact.hash)})`);', + ' process.exit(0);', + ' }', ' console.log(`Already installed ${pluginName}@${pluginVersion} at ${destination} (content ${short(artifact.hash)})`);', ' process.exit(0);', '}', @@ -382,6 +393,14 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { " const owned = ownership === 'receipt'", ' ? receipt.files', " : (await inventory(destination)).files.filter((file) => !preservedEntries.includes(file.split('/')[0]));", + ' const ownedSet = new Set(owned);', + ' const collisions = [];', + ' for (const file of staged.inventory.files) {', + ' if (!ownedSet.has(file) && await exists(join(destination, file))) collisions.push(file);', + ' }', + ' if (collisions.length > 0) {', + " throw new Error(`Refusing to overwrite unowned files at ${destination}: ${collisions.join(', ')}. Move them aside manually before replacing.`);", + ' }', ' const incoming = new Set(staged.inventory.files);', ' const stale = owned.filter((file) => !incoming.has(file));', ' for (const file of stale) await rm(join(destination, file), { force: true });', diff --git a/packages/agent-bundle/tests/install-surface.test.ts b/packages/agent-bundle/tests/install-surface.test.ts index 2f1334459..a899f894d 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -142,6 +142,8 @@ it('documents the same-version reinstall recipe per host, including Claude\'s ve 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('\\\\')"); }); const run = async ( @@ -231,6 +233,33 @@ 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'); expect(await readFile(join(destination, 'state', 'plugin.sqlite'), 'utf8')).toBe('durable\n'); + // An incoming file that would land on an existing unowned file aborts before any change. + await writeFile(join(destination, 'notes.md'), 'operator\n'); + await writeFile(join(bundle, 'notes.md'), 'artifact\n'); + const collision = await run(installer, [], home); + expect(collision.code).toBe(1); + expect(collision.stderr).toContain('Refusing to overwrite unowned files'); + expect(collision.stderr).toContain('notes.md'); + expect(await readFile(join(destination, 'notes.md'), 'utf8')).toBe('operator\n'); + 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. + await rm(join(destination, installReceiptFile)); + await rm(join(destination, 'state'), { force: true, recursive: true }); + const identicalLegacy = await run(installer, [], home); + expect(identicalLegacy).toMatchObject({ code: 0, stderr: '' }); + expect(identicalLegacy.stdout).toContain('Already installed install-fixture@1.2.3'); + 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', + }); + // Legacy pre-receipt copy with drift: refused with a hash comparison until --replace adopts it. await rm(join(destination, installReceiptFile)); await writeFile(join(destination, 'payload.txt'), 'legacy\n'); diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index 82d98dafb..2617a4c2b 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -227,18 +227,24 @@ it('honours --replace for Codex through remove + add and fails closed without a 'plugin add install-fixture@install-fixture-marketplace', ]); - const unusable = recordingRunner(() => 'not json'); - const error = await installBundle({ - commandRunner: unusable.runner, - from: fixture.from, - host: 'codex', - replace: true, - scope: 'user', - }).catch((failure: unknown) => failure); - expect(error).toBeInstanceOf(DiagnosticError); - expect((error as DiagnosticError).diagnostics[0]).toMatchObject({ code: 'AB7004', target: 'codex' }); - expect((error as DiagnosticError).diagnostics[0]?.message).toContain('plugin list --json was unusable'); - expect(unusable.calls).toHaveLength(1); + // Unparsable output, and a matching row that cannot be read, both fail --replace closed. + const malformedRow = JSON.stringify({ + installed: [{ installed: true, pluginId: 'install-fixture@install-fixture-marketplace' }], + }); + for (const stdout of ['not json', malformedRow]) { + const unusable = recordingRunner(() => stdout); + const error = await installBundle({ + commandRunner: unusable.runner, + from: fixture.from, + host: 'codex', + replace: true, + scope: 'user', + }).catch((failure: unknown) => failure); + expect(error, stdout).toBeInstanceOf(DiagnosticError); + expect((error as DiagnosticError).diagnostics[0]).toMatchObject({ code: 'AB7004', target: 'codex' }); + expect((error as DiagnosticError).diagnostics[0]?.message).toContain('plugin list --json was unusable'); + expect(unusable.calls).toHaveLength(1); + } } finally { await rm(fixture.cleanupRoot, { force: true, recursive: true }); } @@ -383,6 +389,18 @@ it('replaces a stale same-version receipt-managed Cursor install in place, touch const again = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); expect(again).toMatchObject({ contentHash: artifact.hash, state: 'already-installed' }); expect(again.previousContentHash).toBeUndefined(); + + // An incoming file that would land on an existing unowned file aborts before any change. + await writeFile(join(fixture.bundleRoot, 'operator-note.txt'), 'from the artifact\n'); + const collision = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }) + .catch((failure: unknown) => failure); + expect(collision).toBeInstanceOf(DiagnosticError); + expect((collision as DiagnosticError).diagnostics[0]).toMatchObject({ code: 'AB7004', target: 'cursor' }); + expect((collision as DiagnosticError).diagnostics[0]?.message).toContain('Refusing to overwrite unowned files'); + expect((collision as DiagnosticError).diagnostics[0]?.message).toContain('operator-note.txt'); + expect(await readFile(join(destination, 'operator-note.txt'), 'utf8')).toBe('keep me\n'); + expect(await readFile(join(destination, 'payload.txt'), 'utf8')).toBe('rebuilt\n'); + expect(await readInstallReceipt(destination)).toMatchObject({ contentHash: artifact.hash }); } finally { await Promise.all([ rm(fixture.cleanupRoot, { force: true, recursive: true }), @@ -400,6 +418,16 @@ it('requires --replace for a legacy pre-receipt Cursor copy and then adopts it', await writeFile(join(fixture.bundleRoot, 'INSTALL.md'), '# install\n'); await writeFile(join(fixture.bundleRoot, 'install.mjs'), '// installer\n'); await cp(fixture.bundleRoot, destination, { recursive: true }); + + // 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' }); + 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' }); + expect(await readInstallReceipt(destination)).toMatchObject({ plugin: 'install-fixture', version: '1.2.3' }); + await rm(join(destination, installReceiptFile)); + await writeFile(join(destination, 'payload.txt'), 'stale\n'); const legacyHash = (await treeInventory(destination)).hash; const artifact = await treeInventory(fixture.bundleRoot); @@ -415,8 +443,8 @@ it('requires --replace for a legacy pre-receipt Cursor copy and then adopts it', expect(message).toContain('same version, different content'); expect(message).toContain('--replace'); - const adopted = await installBundle({ from: fixture.from, home, host: 'cursor', replace: true, scope: 'user' }); - expect(adopted).toMatchObject({ contentHash: artifact.hash, previousContentHash: legacyHash, state: 'replaced' }); + 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 readInstallReceipt(destination)).toMatchObject({ contentHash: artifact.hash, plugin: 'install-fixture' }); } finally { @@ -451,6 +479,36 @@ it('fails closed when Cursor is not detected in the selected home', async () => } }); +it('ignores receipts whose file list could escape the plugin root', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-receipt-')); + try { + for (const files of [['..\\outside'], ['../outside'], ['/etc/passwd'], ['a//b'], ['./x'], ['C:/x'], [installReceiptFile]]) { + await writeJson(join(root, installReceiptFile), { + contentHash: 'abc', + files, + format: 'agent-bundle-install-receipt/1', + host: 'cursor', + installedAt: '2026-09-03T00:00:00.000Z', + plugin: 'install-fixture', + version: '1.2.3', + }); + expect(await readInstallReceipt(root), JSON.stringify(files)).toBeUndefined(); + } + await writeJson(join(root, installReceiptFile), { + contentHash: 'abc', + files: ['skills/probe/SKILL.md', 'plugin.json'], + format: 'agent-bundle-install-receipt/1', + host: 'cursor', + installedAt: '2026-09-03T00:00:00.000Z', + plugin: 'install-fixture', + version: '1.2.3', + }); + expect(await readInstallReceipt(root)).toMatchObject({ files: ['skills/probe/SKILL.md', 'plugin.json'] }); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + it('refuses foreign Cursor directories even with --replace and gates version collisions behind it', async () => { const fixture = await createHostBundle('cursor'); const home = await mkdtemp(join(tmpdir(), 'agent-bundle-home-')); From 3f17e3779f6fc7f6c1c02aab8d3869cb90dc9834 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 09:39:50 +0000 Subject: [PATCH 03/22] fix(install): guard symlinked ancestors and case aliases; patch changeset (#400) - Refuse any owned or incoming path whose ancestor directory is a symlink before hashing or writing (development installs re-point top-level dirs). - Decide unowned-file collisions by inode identity so a case-only rename of an owned path is not a collision on case-insensitive filesystems. - Standalone install.mjs validates the full receipt shape like the core reader. - Changeset: patch (pre-1.0 features are patch), summary ends with (#420). --- .changeset/400-install-replace.md | 4 +- packages/agent-bundle/src/install/receipt.ts | 52 ++++++++++++++++++- packages/agent-bundle/src/install/surface.ts | 38 +++++++++++++- .../tests/install-surface.test.ts | 27 +++++++++- packages/agent-bundle/tests/install.test.ts | 51 +++++++++++++++++- 5 files changed, 163 insertions(+), 9 deletions(-) diff --git a/.changeset/400-install-replace.md b/.changeset/400-install-replace.md index 65e0313d7..27cf02ad1 100644 --- a/.changeset/400-install-replace.md +++ b/.changeset/400-install-replace.md @@ -1,5 +1,5 @@ --- -"agent-bundle": minor +"agent-bundle": patch --- -Add a same-version replace path to every emitted installer (#400). `agent-bundle install `, the package-relative installer bin, and the emitted standalone `install.mjs` accept `--replace` (alias `--force`) and automatically replace an agent-bundle install of the same plugin whose version matches but whose content hash differs, so rebuilding without a version bump no longer needs a manual uninstall + `rm -rf`. Cursor copies now carry an install receipt (`.agent-bundle-install.json`: plugin, version, content hash, owned files); replacement is in place and touches owned files only, leaving `state/` and other unowned entries intact. Claude replacement runs `plugin uninstall --keep-data` before reinstalling because `plugin update` is version-gated; Codex runs `plugin remove` before `add`. Foreign directories are still refused, now with an installed-versus-artifact content-hash comparison in the message. `agent-bundle doctor --from` reports the installed copy per host as `current`, `stale (same version, different content)`, `version-mismatch`, `foreign` (new `AB7321`), or `not-installed`, and emitted `INSTALL.md` documents the reinstall recipe per host. +Add `--replace` (alias `--force`) to `agent-bundle install `, the package-relative installer bin, and the emitted standalone `install.mjs`, and replace a same-version stale copy automatically: an agent-bundle install of the same plugin whose version matches but whose content hash differs is replaced without a flag, identical reruns stay an `already-installed` no-op, and rebuilding without a version bump no longer needs a manual uninstall + `rm -rf`. Cursor copies now carry an install receipt (`.agent-bundle-install.json`: plugin, version, host, content hash, owned files); replacement is in place, touches owned files only, refuses to overwrite unowned entries such as `state/`, and `--replace` adopts a pre-receipt copy (`adopted`). Claude replacement runs `claude plugin uninstall --keep-data` before reinstalling because `plugin update` is version-gated; Codex runs `codex plugin remove` before `add`; both fail `--replace` closed when `plugin list --json` is unusable. Foreign directories are still refused (`AB7005`) with an installed-versus-artifact content-hash comparison. `agent-bundle doctor --from` reports the installed copy per host as `current`, `stale` (`AB7308`), `version-mismatch` (`AB7309`), `foreign` (new `AB7321`), or `not-installed` (`AB7307`), and emitted `INSTALL.md` documents the same-version reinstall recipe per host (#420) diff --git a/packages/agent-bundle/src/install/receipt.ts b/packages/agent-bundle/src/install/receipt.ts index 6dc09204d..b2a912c6a 100644 --- a/packages/agent-bundle/src/install/receipt.ts +++ b/packages/agent-bundle/src/install/receipt.ts @@ -1,4 +1,5 @@ import { createHash } from 'node:crypto'; +import type { Stats } from 'node:fs'; import { cp, lstat, @@ -15,7 +16,7 @@ import { basename, dirname, join } from 'node:path'; import { stableJson } from '../core/digest.ts'; import { isErrno } from '../core/errors.ts'; -import { exists } from '../core/paths.ts'; +import { exists, sameFile } from '../core/paths.ts'; /** * Host-agnostic install ownership core shared by `agent-bundle install`, @@ -122,12 +123,38 @@ export const treeInventory = async (root: string): Promise => { return Object.freeze({ files: Object.freeze(files), hash: hash.digest('hex') }); }; +/** + * Every directory on the way to a listed path must be a real directory: a + * symlinked ancestor would let a leaf-only check read, hash, delete, or write + * outside the plugin root (development installs point top-level directories + * at generation folders, for example). Missing ancestors are fine. + */ +const assertRealAncestors = async (root: string, files: readonly string[]): Promise => { + const checked = new Set(); + for (const file of files) { + let directory = dirname(file); + while (directory !== '.' && directory !== '') { + if (!checked.has(directory)) { + checked.add(directory); + try { + const metadata = await lstat(join(root, directory)); + if (metadata.isSymbolicLink() || !metadata.isDirectory()) throw unsupportedEntry(directory); + } catch (error) { + if (!isErrno(error, 'ENOENT')) throw error; + } + } + directory = dirname(directory); + } + } +}; + /** * Hashes exactly the listed files (in list order) as they exist under root. * A missing owned file contributes nothing, so the digest differs from the * artifact digest whenever an owned file was removed or rewritten. */ export const hashOwnedFiles = async (root: string, files: readonly string[]): Promise => { + await assertRealAncestors(root, files); const hash = createHash('sha256'); for (const relativePath of files) { const path = join(root, relativePath); @@ -387,10 +414,31 @@ export const replaceInstalledTree = async (options: { } const incoming = new Set(options.staged.inventory.files); const previouslyOwned = await previouslyOwnedFiles(options.destination, options.comparison); + await assertRealAncestors(options.destination, [...previouslyOwned, ...options.staged.inventory.files]); const owned = new Set(previouslyOwned); + // On case-insensitive filesystems a case-only rename of an owned path resolves to the same + // inode, so ownership is decided by file identity, not by name alone. + let ownedIdentities: readonly Stats[] | undefined; + const isOwnedIdentity = async (path: string): Promise => { + const candidate = await lstat(path); + if (ownedIdentities === undefined) { + const identities: Stats[] = []; + for (const file of previouslyOwned) { + try { + identities.push(await lstat(join(options.destination, file))); + } catch (error) { + if (!isErrno(error, 'ENOENT')) throw error; + } + } + ownedIdentities = identities; + } + return ownedIdentities.some((metadata) => sameFile(metadata, candidate)); + }; const collisions: string[] = []; for (const file of options.staged.inventory.files) { - if (!owned.has(file) && await exists(join(options.destination, file))) collisions.push(file); + if (owned.has(file)) continue; + const target = join(options.destination, file); + if (await exists(target) && !await isOwnedIdentity(target)) collisions.push(file); } if (collisions.length > 0) { throw new Error( diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index 22566dc32..e40d0dc23 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -238,8 +238,27 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { " return { files, hash: hash.digest('hex') };", '};', '', + '// Every directory on the way to a listed path must be a real directory (no symlinked ancestors).', + 'const assertRealAncestors = async (root, files) => {', + ' const checked = new Set();', + ' for (const file of files) {', + ' let directory = dirname(file);', + " while (directory !== '.' && directory !== '') {", + ' if (!checked.has(directory)) {', + ' checked.add(directory);', + ' try {', + ' const metadata = await lstat(join(root, directory));', + ' if (metadata.isSymbolicLink() || !metadata.isDirectory()) throw unsupported(directory);', + " } catch (error) { if (error?.code !== 'ENOENT') throw error; }", + ' }', + ' directory = dirname(directory);', + ' }', + ' }', + '};', + '', '// Hashes exactly the owned files; a missing owned file simply changes the digest.', 'const hashOwned = async (root, files) => {', + ' await assertRealAncestors(root, files);', " const hash = createHash('sha256');", ' for (const relative of files) {', ' const absolute = join(root, relative);', @@ -261,8 +280,10 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { " try { value = JSON.parse(await readFile(join(root, receiptFile), '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;", + ' // Same shape check as the core reader: a receipt missing any field reads as absent.', " if (value.format !== receiptFormat || typeof value.plugin !== 'string' || typeof value.version !== 'string' ||", - " typeof value.contentHash !== 'string' || !Array.isArray(value.files) || !value.files.every(safeRelative)) return undefined;", + " typeof value.host !== 'string' || typeof value.contentHash !== 'string' || typeof value.installedAt !== 'string' ||", + ' !Array.isArray(value.files) || !value.files.every(safeRelative)) return undefined;', ' return value;', '};', '', @@ -393,10 +414,23 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { " const owned = ownership === 'receipt'", ' ? receipt.files', " : (await inventory(destination)).files.filter((file) => !preservedEntries.includes(file.split('/')[0]));", + ' await assertRealAncestors(destination, [...owned, ...staged.inventory.files]);', ' const ownedSet = new Set(owned);', + ' // Case-insensitive filesystems: a case-only rename of an owned path is the same inode, not a collision.', + ' let ownedIdentities;', + ' const isOwnedIdentity = async (path) => {', + ' const candidate = await lstat(path);', + ' ownedIdentities ??= (await Promise.all(owned.map(async (file) => {', + ' try { return await lstat(join(destination, file)); }', + " catch (error) { if (error?.code === 'ENOENT') return undefined; throw error; }", + ' }))).filter((metadata) => metadata !== undefined);', + ' return ownedIdentities.some((metadata) => metadata.dev === candidate.dev && metadata.ino === candidate.ino);', + ' };', ' const collisions = [];', ' for (const file of staged.inventory.files) {', - ' if (!ownedSet.has(file) && await exists(join(destination, file))) collisions.push(file);', + ' if (ownedSet.has(file)) continue;', + ' const target = join(destination, file);', + ' if (await exists(target) && !(await isOwnedIdentity(target))) collisions.push(file);', ' }', ' if (collisions.length > 0) {', " throw new Error(`Refusing to overwrite unowned files at ${destination}: ${collisions.join(', ')}. Move them aside manually before replacing.`);", diff --git a/packages/agent-bundle/tests/install-surface.test.ts b/packages/agent-bundle/tests/install-surface.test.ts index a899f894d..535cc4d01 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -1,5 +1,5 @@ import { execFile as executeFile } from 'node:child_process'; -import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { promisify } from 'node:util'; @@ -260,6 +260,31 @@ it('emitted install.mjs mirrors the core replace policy: no-op, owned-only repla plugin: 'install-fixture', }); + // A receipt missing a field reads as absent, exactly like the core reader: the legacy gate applies. + 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'); + await writeFile(join(bundle, 'payload.txt'), 'rebuilt\n'); + + // An unowned symlinked directory beneath which the artifact starts writing: refused before any change. + await writeFile(join(destination, installReceiptFile), JSON.stringify(receipt)); + const elsewhere = join(root, 'elsewhere'); + await mkdir(elsewhere); + await symlink(elsewhere, join(destination, 'skills')); + await mkdir(join(bundle, 'skills', 'new'), { recursive: true }); + await writeFile(join(bundle, 'skills', 'new', 'SKILL.md'), '# new\n'); + const symlinked = await run(installer, [], home); + expect(symlinked.code).toBe(1); + expect(symlinked.stderr).toContain('Refusing unsupported filesystem entry "skills"'); + expect(await readdir(elsewhere)).toEqual([]); + await rm(join(destination, 'skills')); + await rm(join(bundle, 'skills'), { recursive: true }); + // Legacy pre-receipt copy with drift: refused with a hash comparison until --replace adopts it. await rm(join(destination, installReceiptFile)); await writeFile(join(destination, 'payload.txt'), 'legacy\n'); diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index 2617a4c2b..840661e0b 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -479,6 +479,47 @@ it('fails closed when Cursor is not detected in the selected home', async () => } }); +it('refuses to hash or write through a symlinked directory inside a receipt-managed Cursor install', async () => { + const fixture = await createHostBundle('cursor'); + const home = await mkdtemp(join(tmpdir(), 'agent-bundle-home-')); + const elsewhere = await mkdtemp(join(tmpdir(), 'agent-bundle-elsewhere-')); + await mkdir(join(home, '.cursor')); + const destination = join(home, '.cursor', 'plugins', 'local', 'install-fixture'); + try { + await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); + + // An unowned symlinked directory that a rebuilt artifact starts writing beneath: refused before any change. + await symlink(elsewhere, join(destination, 'skills')); + await writeFile(join(fixture.bundleRoot, 'payload.txt'), 'rebuilt\n'); + await mkdir(join(fixture.bundleRoot, 'skills', 'new'), { recursive: true }); + await writeFile(join(fixture.bundleRoot, 'skills', 'new', 'SKILL.md'), '# new\n'); + const incoming = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }) + .catch((failure: unknown) => failure); + expect(incoming).toBeInstanceOf(DiagnosticError); + expect((incoming as DiagnosticError).diagnostics[0]).toMatchObject({ code: 'AB7004', target: 'cursor' }); + expect((incoming as DiagnosticError).diagnostics[0]?.message).toContain('Refusing unsupported filesystem entry "skills"'); + expect(await readdir(elsewhere)).toEqual([]); + expect(await readFile(join(destination, 'payload.txt'), 'utf8')).toBe('payload\n'); + + // An owned path whose ancestor became a symlink (development installs re-point top-level directories). + await rm(join(destination, 'skills')); + await rm(join(fixture.bundleRoot, 'skills'), { recursive: true }); + await cp(join(destination, '.cursor-plugin'), join(destination, '.real-manifest'), { recursive: true }); + await rm(join(destination, '.cursor-plugin'), { recursive: true }); + await symlink(join(destination, '.real-manifest'), join(destination, '.cursor-plugin')); + const owned = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }) + .catch((failure: unknown) => failure); + expect(owned).toBeInstanceOf(DiagnosticError); + expect((owned as DiagnosticError).diagnostics[0]?.message).toContain('Refusing unsupported filesystem entry ".cursor-plugin"'); + } finally { + await Promise.all([ + rm(fixture.cleanupRoot, { force: true, recursive: true }), + rm(home, { force: true, recursive: true }), + rm(elsewhere, { force: true, recursive: true }), + ]); + } +}); + it('ignores receipts whose file list could escape the plugin root', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-receipt-')); try { @@ -494,7 +535,7 @@ it('ignores receipts whose file list could escape the plugin root', async () => }); expect(await readInstallReceipt(root), JSON.stringify(files)).toBeUndefined(); } - await writeJson(join(root, installReceiptFile), { + const complete = { contentHash: 'abc', files: ['skills/probe/SKILL.md', 'plugin.json'], format: 'agent-bundle-install-receipt/1', @@ -502,7 +543,13 @@ it('ignores receipts whose file list could escape the plugin root', async () => installedAt: '2026-09-03T00:00:00.000Z', plugin: 'install-fixture', version: '1.2.3', - }); + }; + for (const missing of ['host', 'installedAt', 'contentHash', 'version', 'plugin', 'format'] as const) { + const { [missing]: _omitted, ...partial } = complete; + await writeJson(join(root, installReceiptFile), partial); + expect(await readInstallReceipt(root), missing).toBeUndefined(); + } + await writeJson(join(root, installReceiptFile), complete); expect(await readInstallReceipt(root)).toMatchObject({ files: ['skills/probe/SKILL.md', 'plugin.json'] }); } finally { await rm(root, { force: true, recursive: true }); From 27e1a0d975cb68aae37b6b14d02ebd10a4d4fa2e Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 09:54:01 +0000 Subject: [PATCH 04/22] fix(install): receipt inventory, owned restructures, multi-scope doctor (#400) - A receipt-managed tree is current only when its recorded file inventory matches the artifact as well as the hash, so a vanished-and-dropped owned file still refreshes the receipt instead of leaving a stale owned entry. - Allow owned file<->directory restructures: an owned regular file may stand where the rebuild wants a directory, and a directory whose contents are all owned may stand where the rebuild wants a file; anything unowned beneath is still a collision. - Doctor compares every Claude scope copy from plugin list --json and reports the worst, naming the scope in AB7308/AB7309 and their recovery. - install.mjs mirrors the inventory check and restructure tolerance. --- packages/agent-bundle/src/install/doctor.ts | 134 ++++++++++-------- packages/agent-bundle/src/install/install.ts | 48 ++++--- packages/agent-bundle/src/install/receipt.ts | 46 +++++- packages/agent-bundle/src/install/surface.ts | 26 +++- packages/agent-bundle/tests/doctor.test.ts | 13 ++ .../tests/install-surface.test.ts | 24 ++++ packages/agent-bundle/tests/install.test.ts | 56 ++++++++ 7 files changed, 254 insertions(+), 93 deletions(-) diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index 503595e2c..600329dfc 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -840,8 +840,8 @@ const readPublicHostInventory = async ( }); }; -const publicHostReplaceRecipe = (host: Exclude): string => host === 'claude' - ? 'Rerun `agent-bundle install claude --from `; same-version content drift is replaced through ' + +const publicHostReplaceRecipe = (host: Exclude, scopeArguments = ''): string => host === 'claude' + ? `Rerun \`agent-bundle install claude --from ${scopeArguments}\`; same-version content drift is replaced through ` + '`claude plugin uninstall --keep-data` + `claude plugin install` because Claude\'s `plugin update` is version-gated.' : 'Rerun `agent-bundle install codex --from `; same-version content drift is replaced through ' + '`codex plugin remove` + `codex plugin add`.'; @@ -863,8 +863,7 @@ const publicHostInstallComparison = async ( diagnostics: Object.freeze([]), }; } - const entry = inventory.entry; - if (entry === undefined) { + if (inventory.entries.length === 0) { return { comparison: Object.freeze({ artifactContentHash: artifact.hash, status: 'not-installed' }), diagnostics: freezeDiagnostics([diagnostic( @@ -876,80 +875,93 @@ const publicHostInstallComparison = async ( )]), }; } - let installed: TreeInventory; - try { - installed = await treeInventory(entry.installPath); - } catch (error) { - return { - comparison: Object.freeze({ + // Claude may hold one copy per scope; every copy is compared and the worst one is summarised, + // so a current user-scoped copy never masks a stale project- or local-scoped one. + const diagnostics: Diagnostic[] = []; + const comparisons: DoctorInstallComparison[] = []; + for (const entry of inventory.entries) { + const scoped = entry.scope === undefined ? '' : ` (scope ${entry.scope})`; + const replaceHint = entry.scope === undefined ? '' : ` --scope ${entry.scope}`; + let installed: TreeInventory; + try { + installed = await treeInventory(entry.installPath); + } catch (error) { + comparisons.push(Object.freeze({ artifactContentHash: artifact.hash, installedPath: entry.installPath, ...(entry.version === undefined ? {} : { installedVersion: entry.version }), ownership: 'host', status: 'unknown', - }), - diagnostics: freezeDiagnostics([diagnostic( + })); + diagnostics.push(diagnostic( 'AB7310', - `${host} installed copy at ${JSON.stringify(entry.installPath)} could not be compared: ` + + `${host} installed copy at ${JSON.stringify(entry.installPath)}${scoped} could not be compared: ` + `${error instanceof Error ? error.message : String(error)}`, - `Reinstall the ${host} plugin with \`agent-bundle install ${host} --from --replace\`.`, + `Reinstall the ${host} plugin with \`agent-bundle install ${host} --from ${replaceHint} --replace\`.`, 'error', host, - )]), - }; - } - const status: 'current' | 'stale' | 'version-mismatch' = - installed.hash === artifact.hash && entry.version === identity.version - ? 'current' - : entry.version !== undefined && entry.version !== identity.version - ? 'version-mismatch' - : 'stale'; - const comparison: DoctorInstallComparison = Object.freeze({ - artifactContentHash: artifact.hash, - installedContentHash: installed.hash, - installedPath: entry.installPath, - ...(entry.version === undefined ? {} : { installedVersion: entry.version }), - ownership: 'host', - status, - }); - const detail = describeContentComparison(identity.name, identity.version, { - artifactContentHash: artifact.hash, - installedContentHash: installed.hash, - installedName: identity.name, - ...(entry.version === undefined ? {} : { installedVersion: entry.version }), - status, - }); - switch (status) { - case 'current': - return { comparison, diagnostics: Object.freeze([]) }; - case 'stale': - return { - comparison, - diagnostics: freezeDiagnostics([diagnostic( + )); + continue; + } + const status: 'current' | 'stale' | 'version-mismatch' = + installed.hash === artifact.hash && entry.version === identity.version + ? 'current' + : entry.version !== undefined && entry.version !== identity.version + ? 'version-mismatch' + : 'stale'; + comparisons.push(Object.freeze({ + artifactContentHash: artifact.hash, + installedContentHash: installed.hash, + installedPath: entry.installPath, + ...(entry.version === undefined ? {} : { installedVersion: entry.version }), + ownership: 'host', + status, + })); + const detail = describeContentComparison(identity.name, identity.version, { + artifactContentHash: artifact.hash, + installedContentHash: installed.hash, + installedName: identity.name, + ...(entry.version === undefined ? {} : { installedVersion: entry.version }), + status, + }); + switch (status) { + case 'current': + break; + case 'stale': + diagnostics.push(diagnostic( 'AB7308', - `${host} plugin ${identity.name}@${identity.version} at ${entry.installPath} is stale ` + + `${host} plugin ${identity.name}@${identity.version} at ${entry.installPath}${scoped} is stale ` + `(same version, different content): ${detail}.`, - publicHostReplaceRecipe(host), + publicHostReplaceRecipe(host, replaceHint), 'warning', host, - )]), - }; - case 'version-mismatch': - return { - comparison, - diagnostics: freezeDiagnostics([diagnostic( + )); + break; + case 'version-mismatch': + diagnostics.push(diagnostic( 'AB7309', - `${host} version collision at ${entry.installPath}: ${detail}.`, - `Rerun \`agent-bundle install ${host} --from --replace\` to replace the installed version.`, + `${host} version collision at ${entry.installPath}${scoped}: ${detail}.`, + `Rerun \`agent-bundle install ${host} --from ${replaceHint} --replace\` to replace the installed version.`, 'warning', host, - )]), - }; - default: { - const exhaustive: never = status; - throw new TypeError(`Unknown install comparison ${String(exhaustive)}.`); + )); + break; + default: { + const exhaustive: never = status; + throw new TypeError(`Unknown install comparison ${String(exhaustive)}.`); + } } } + const severity: Record = { + current: 0, + 'not-installed': 1, + unknown: 2, + stale: 3, + 'version-mismatch': 4, + foreign: 5, + }; + const worst = comparisons.reduce((left, right) => severity[right.status] > severity[left.status] ? right : left); + return { comparison: worst, diagnostics: freezeDiagnostics(diagnostics) }; }; const cursorBundle = async ( @@ -1237,7 +1249,7 @@ const codexBundle = async ( finding: Object.freeze({ ...base, comparison: compared.comparison, - state: inventory.entry === undefined ? 'missing' : 'installed', + state: inventory.entries.length === 0 ? 'missing' : 'installed', }), }; }; diff --git a/packages/agent-bundle/src/install/install.ts b/packages/agent-bundle/src/install/install.ts index 5ddf593bf..bab07f805 100644 --- a/packages/agent-bundle/src/install/install.ts +++ b/packages/agent-bundle/src/install/install.ts @@ -240,12 +240,18 @@ export const publicHostCacheRoot = ( export interface PublicHostInstalledEntry { readonly installPath: string; + /** Claude only: the scope this copy is installed at. */ + readonly scope?: string; readonly version?: string; } -/** The host's own answer to "is this plugin installed, and where": usable, or not, never guessed. */ +/** + * The host's own answer to "is this plugin installed, and where": usable, or + * not, never guessed. Claude can hold one copy per scope, so an unscoped read + * returns every matching copy; a scoped read returns at most one. + */ export type PublicHostInventory = - | { readonly entry?: PublicHostInstalledEntry; readonly status: 'available' } + | { readonly entries: readonly PublicHostInstalledEntry[]; readonly status: 'available' } | { readonly detail: string; readonly status: 'unavailable' }; const isRecord = (value: unknown): value is Record => @@ -280,37 +286,38 @@ export const parsePublicHostInventory = ( if (!Array.isArray(document)) { return { detail: 'claude plugin list --json did not return an array', status: 'unavailable' }; } - const row = document.find((candidate) => + const rows = document.filter((candidate): candidate is Record => isRecord(candidate) && candidate['id'] === id && (options.scope === undefined || candidate['scope'] === options.scope)); - if (row === undefined) return { status: 'available' }; - // A matching row we cannot read is not "not installed": replacing on it would skip the uninstall. - if (!isRecord(row) || typeof row['installPath'] !== 'string') { - return { detail: `claude plugin list --json row for ${id} carries no installPath`, status: 'unavailable' }; - } - return { - entry: { + const entries: PublicHostInstalledEntry[] = []; + for (const row of rows) { + // A matching row we cannot read is not "not installed": replacing on it would skip the uninstall. + if (typeof row['installPath'] !== 'string') { + return { detail: `claude plugin list --json row for ${id} carries no installPath`, status: 'unavailable' }; + } + entries.push({ installPath: row['installPath'], + ...(typeof row['scope'] === 'string' ? { scope: row['scope'] } : {}), ...(typeof row['version'] === 'string' ? { version: row['version'] } : {}), - }, - status: 'available', - }; + }); + } + return { entries, status: 'available' }; } if (!isRecord(document) || !Array.isArray(document['installed'])) { return { detail: 'codex plugin list --json did not return an installed array', status: 'unavailable' }; } const row = document['installed'].find((candidate) => isRecord(candidate) && candidate['pluginId'] === id && candidate['installed'] !== false); - if (row === undefined) return { status: 'available' }; + if (row === undefined) return { entries: [], status: 'available' }; if (!isRecord(row) || typeof row['version'] !== 'string') { return { detail: `codex plugin list --json row for ${id} carries no version`, status: 'unavailable' }; } return { - entry: { + entries: [{ installPath: join(options.cacheRoot, options.marketplace, options.plugin, row['version']), version: row['version'], - }, + }], status: 'available', }; }; @@ -388,16 +395,17 @@ const installPublicCli = async ( } as const; let replaced = false; let previousContentHash: string | undefined; - if (inventory.status === 'available' && inventory.entry !== undefined) { + const entry = inventory.status === 'available' ? inventory.entries[0] : undefined; + if (entry !== undefined) { let installed: TreeInventory | undefined; try { - installed = await treeInventory(inventory.entry.installPath); + installed = await treeInventory(entry.installPath); } catch { installed = undefined; } - const sameVersion = inventory.entry.version === identity.version; + const sameVersion = entry.version === identity.version; if (installed !== undefined && sameVersion && installed.hash === artifact.hash) { - return { ...base, destination: inventory.entry.installPath, state: 'already-installed' }; + return { ...base, destination: entry.installPath, state: 'already-installed' }; } const contentDrift = installed !== undefined && sameVersion && installed.hash !== artifact.hash; if (options.replace === true || contentDrift) { diff --git a/packages/agent-bundle/src/install/receipt.ts b/packages/agent-bundle/src/install/receipt.ts index b2a912c6a..d690aaebd 100644 --- a/packages/agent-bundle/src/install/receipt.ts +++ b/packages/agent-bundle/src/install/receipt.ts @@ -127,9 +127,15 @@ export const treeInventory = async (root: string): Promise => { * Every directory on the way to a listed path must be a real directory: a * symlinked ancestor would let a leaf-only check read, hash, delete, or write * outside the plugin root (development installs point top-level directories - * at generation folders, for example). Missing ancestors are fine. + * at generation folders, for example). Missing ancestors are fine, and so is + * an owned regular file that a rebuild turns into a directory: it leaves as + * stale before anything is written beneath it. */ -const assertRealAncestors = async (root: string, files: readonly string[]): Promise => { +const assertRealAncestors = async ( + root: string, + files: readonly string[], + ownedFiles: ReadonlySet = new Set(), +): Promise => { const checked = new Set(); for (const file of files) { let directory = dirname(file); @@ -138,7 +144,10 @@ const assertRealAncestors = async (root: string, files: readonly string[]): Prom checked.add(directory); try { const metadata = await lstat(join(root, directory)); - if (metadata.isSymbolicLink() || !metadata.isDirectory()) throw unsupportedEntry(directory); + if (metadata.isSymbolicLink()) throw unsupportedEntry(directory); + if (!metadata.isDirectory() && !(metadata.isFile() && ownedFiles.has(directory))) { + throw unsupportedEntry(directory); + } } catch (error) { if (!isErrno(error, 'ENOENT')) throw error; } @@ -287,7 +296,14 @@ export const compareInstalledTree = async (options: { } const installedVersion = manifest?.version ?? (ownership === 'receipt' ? receipt?.version : undefined); const installedName = manifest?.name ?? (ownership === 'receipt' ? receipt?.plugin : undefined); - const status: InstalledTreeStatus = installedContentHash === options.artifact.hash + // A receipt-managed tree is current only when its recorded inventory matches too: an owned file + // that vanished while the rebuild dropped it hashes equal, but the receipt must still be refreshed + // so a later unowned file at that path is never mistaken for stale owned content. + const inventoryMatches = ownership !== 'receipt' || + (receipt !== undefined && + receipt.files.length === options.artifact.files.length && + receipt.files.every((file, index) => file === options.artifact.files[index])); + const status: InstalledTreeStatus = installedContentHash === options.artifact.hash && inventoryMatches ? 'current' : ownership === 'foreign' ? 'foreign' @@ -414,8 +430,9 @@ export const replaceInstalledTree = async (options: { } const incoming = new Set(options.staged.inventory.files); const previouslyOwned = await previouslyOwnedFiles(options.destination, options.comparison); - await assertRealAncestors(options.destination, [...previouslyOwned, ...options.staged.inventory.files]); const owned = new Set(previouslyOwned); + await assertRealAncestors(options.destination, previouslyOwned); + await assertRealAncestors(options.destination, options.staged.inventory.files, owned); // On case-insensitive filesystems a case-only rename of an owned path resolves to the same // inode, so ownership is decided by file identity, not by name alone. let ownedIdentities: readonly Stats[] | undefined; @@ -434,11 +451,28 @@ export const replaceInstalledTree = async (options: { } return ownedIdentities.some((metadata) => sameFile(metadata, candidate)); }; + // An existing directory at an incoming file path is fine only when everything beneath it is + // owned: those files leave as stale and the emptied directory is pruned before the rename. + const isWhollyOwnedDirectory = async (file: string): Promise => { + const nested = await treeInventory(join(options.destination, file)); + return nested.files.every((entry) => owned.has(`${file}/${entry}`)); + }; const collisions: string[] = []; for (const file of options.staged.inventory.files) { if (owned.has(file)) continue; const target = join(options.destination, file); - if (await exists(target) && !await isOwnedIdentity(target)) collisions.push(file); + let metadata: Stats; + try { + metadata = await lstat(target); + } catch (error) { + // ENOTDIR: an owned file stands where the rebuild wants a directory; it leaves as stale first. + if (isErrno(error, 'ENOENT') || isErrno(error, 'ENOTDIR')) continue; + throw error; + } + const tolerated = metadata.isDirectory() && !metadata.isSymbolicLink() + ? await isWhollyOwnedDirectory(file) + : await isOwnedIdentity(target); + if (!tolerated) collisions.push(file); } if (collisions.length > 0) { throw new Error( diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index e40d0dc23..7615dc768 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -238,8 +238,9 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { " return { files, hash: hash.digest('hex') };", '};', '', - '// Every directory on the way to a listed path must be a real directory (no symlinked ancestors).', - 'const assertRealAncestors = async (root, files) => {', + '// Every directory on the way to a listed path must be a real directory (no symlinked ancestors);', + '// an owned regular file that a rebuild turns into a directory leaves as stale first, so it is tolerated.', + 'const assertRealAncestors = async (root, files, ownedFiles = new Set()) => {', ' const checked = new Set();', ' for (const file of files) {', ' let directory = dirname(file);', @@ -248,7 +249,8 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' checked.add(directory);', ' try {', ' const metadata = await lstat(join(root, directory));', - ' if (metadata.isSymbolicLink() || !metadata.isDirectory()) throw unsupported(directory);', + ' if (metadata.isSymbolicLink()) throw unsupported(directory);', + ' if (!metadata.isDirectory() && !(metadata.isFile() && ownedFiles.has(directory))) throw unsupported(directory);', " } catch (error) { if (error?.code !== 'ENOENT') throw error; }", ' }', ' directory = dirname(directory);', @@ -378,7 +380,10 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { '}', "const installedVersion = manifest?.version ?? (ownership === 'receipt' ? receipt.version : undefined);", "const installedName = manifest?.name ?? (ownership === 'receipt' ? receipt.plugin : pluginName);", - 'if (installedHash === artifact.hash) {', + '// A receipt-managed copy is current only when its recorded inventory matches the artifact too.', + "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.', ' const temporary = join(destination, `${receiptFile}.${process.pid}.tmp`);', @@ -414,8 +419,9 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { " const owned = ownership === 'receipt'", ' ? receipt.files', " : (await inventory(destination)).files.filter((file) => !preservedEntries.includes(file.split('/')[0]));", - ' await assertRealAncestors(destination, [...owned, ...staged.inventory.files]);', ' const ownedSet = new Set(owned);', + ' await assertRealAncestors(destination, owned);', + ' await assertRealAncestors(destination, staged.inventory.files, ownedSet);', ' // Case-insensitive filesystems: a case-only rename of an owned path is the same inode, not a collision.', ' let ownedIdentities;', ' const isOwnedIdentity = async (path) => {', @@ -426,11 +432,19 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' }))).filter((metadata) => metadata !== undefined);', ' return ownedIdentities.some((metadata) => metadata.dev === candidate.dev && metadata.ino === candidate.ino);', ' };', + ' // An existing directory at an incoming file path is fine only when everything beneath it is owned.', + ' const isWhollyOwnedDirectory = async (file) =>', + ' (await inventory(join(destination, file))).files.every((entry) => ownedSet.has(`${file}/${entry}`));', ' const collisions = [];', ' for (const file of staged.inventory.files) {', ' if (ownedSet.has(file)) continue;', ' const target = join(destination, file);', - ' if (await exists(target) && !(await isOwnedIdentity(target))) collisions.push(file);', + ' let metadata;', + " try { metadata = await lstat(target); } catch (error) { if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') continue; throw error; }", + ' const tolerated = metadata.isDirectory() && !metadata.isSymbolicLink()', + ' ? await isWhollyOwnedDirectory(file)', + ' : await isOwnedIdentity(target);', + ' if (!tolerated) collisions.push(file);', ' }', ' if (collisions.length > 0) {', " throw new Error(`Refusing to overwrite unowned files at ${destination}: ${collisions.join(', ')}. Move them aside manually before replacing.`);", diff --git a/packages/agent-bundle/tests/doctor.test.ts b/packages/agent-bundle/tests/doctor.test.ts index 627777464..854973bd9 100644 --- a/packages/agent-bundle/tests/doctor.test.ts +++ b/packages/agent-bundle/tests/doctor.test.ts @@ -1179,6 +1179,19 @@ it('compares the Claude cache copy reported by plugin list --json against the ar expect(mismatch.diagnostics).toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB7309', severity: 'warning', target: 'claude' }), ])); + + // A current user-scoped copy never masks a stale copy at another scope. + const currentCache = join(fixture.root, 'claude-config', 'plugins', 'cache', 'doctor-fixture-marketplace', 'doctor-fixture', 'current'); + await cp(bundle, currentCache, { recursive: true }); + inventory = [ + { enabled: true, id: 'doctor-fixture@doctor-fixture-marketplace', installPath: currentCache, scope: 'user', version: '1.2.3' }, + { enabled: true, id: 'doctor-fixture@doctor-fixture-marketplace', installPath: installed, scope: 'project', version: '1.2.3' }, + ]; + const scoped = hostReport(await doctor(), 'claude'); + expect(scoped.bundle?.comparison).toMatchObject({ installedPath: installed, status: 'stale' }); + const scopedDiagnostic = scoped.diagnostics.find((entry) => entry.code === 'AB7308'); + expect(scopedDiagnostic?.message).toContain('(scope project)'); + expect(scopedDiagnostic?.recovery).toContain('--scope project'); } finally { await fixture.cleanup(); } diff --git a/packages/agent-bundle/tests/install-surface.test.ts b/packages/agent-bundle/tests/install-surface.test.ts index 535cc4d01..1f94d2fce 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -260,6 +260,30 @@ it('emitted install.mjs mirrors the core replace policy: no-op, owned-only repla plugin: 'install-fixture', }); + // Owned file -> directory restructure is a replacement, not a collision. + await rm(join(bundle, 'payload.txt')); + await mkdir(join(bundle, 'payload.txt')); + await writeFile(join(bundle, 'payload.txt', 'nested.md'), '# nested\n'); + const restructured = await run(installer, [], home); + expect(restructured).toMatchObject({ code: 0, stderr: '' }); + expect(restructured.stdout).toContain('Replaced install-fixture@1.2.3'); + expect(await readFile(join(destination, 'payload.txt', 'nested.md'), 'utf8')).toBe('# nested\n'); + await rm(join(bundle, 'payload.txt'), { recursive: true }); + await writeFile(join(bundle, 'payload.txt'), 'rebuilt\n'); + const flattened = await run(installer, [], home); + expect(flattened).toMatchObject({ code: 0, stderr: '' }); + expect(flattened.stdout).toContain('Replaced install-fixture@1.2.3'); + expect(await readFile(join(destination, 'payload.txt'), 'utf8')).toBe('rebuilt\n'); + + // A receipt whose inventory drifted is refreshed even when the owned bytes hash equal. + await writeFile(join(bundle, 'transient.txt'), 'transient\n'); + await run(installer, [], home); + await rm(join(bundle, 'transient.txt')); + await rm(join(destination, 'transient.txt')); + const refreshed = await run(installer, [], home); + 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. const receipt = JSON.parse(await readFile(join(destination, installReceiptFile), 'utf8')) as Record; const { host: _host, ...partialReceipt } = receipt; diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index 840661e0b..7b0524373 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -479,6 +479,62 @@ it('fails closed when Cursor is not detected in the selected home', async () => } }); +it('refreshes a receipt whose inventory drifted even when the owned bytes hash equal, and restructures owned paths', 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 { + await writeFile(join(fixture.bundleRoot, 'removed-later.txt'), 'old\n'); + await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); + + // The owned file vanished and the rebuild dropped it too: bytes hash equal, inventory does not. + await rm(join(destination, 'removed-later.txt')); + await rm(join(fixture.bundleRoot, 'removed-later.txt')); + const refreshed = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); + expect(refreshed).toMatchObject({ state: 'replaced' }); + expect((await readInstallReceipt(destination))?.files).toEqual(['.cursor-plugin/plugin.json', 'payload.txt']); + // A later unowned file at that path is never mistaken for stale owned content. + await writeFile(join(destination, 'removed-later.txt'), 'operator\n'); + await writeFile(join(fixture.bundleRoot, 'payload.txt'), 'rebuilt\n'); + await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); + expect(await readFile(join(destination, 'removed-later.txt'), 'utf8')).toBe('operator\n'); + await rm(join(destination, 'removed-later.txt')); + + // Owned file -> directory, then directory -> file. + await rm(join(fixture.bundleRoot, 'payload.txt')); + await mkdir(join(fixture.bundleRoot, 'payload.txt')); + await writeFile(join(fixture.bundleRoot, 'payload.txt', 'nested.md'), '# nested\n'); + const toDirectory = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); + expect(toDirectory).toMatchObject({ state: 'replaced' }); + expect(await listFiles(destination)).toEqual([installReceiptFile, '.cursor-plugin/plugin.json', 'payload.txt/nested.md']); + await rm(join(fixture.bundleRoot, 'payload.txt'), { recursive: true }); + await writeFile(join(fixture.bundleRoot, 'payload.txt'), 'flat again\n'); + const toFile = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); + expect(toFile).toMatchObject({ state: 'replaced' }); + expect(await listFiles(destination)).toEqual([installReceiptFile, '.cursor-plugin/plugin.json', 'payload.txt']); + expect(await readFile(join(destination, 'payload.txt'), 'utf8')).toBe('flat again\n'); + + // A directory that also holds an unowned file is a collision, not a restructure. + await rm(join(fixture.bundleRoot, 'payload.txt')); + await mkdir(join(fixture.bundleRoot, 'payload.txt')); + await writeFile(join(fixture.bundleRoot, 'payload.txt', 'nested.md'), '# nested\n'); + await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); + await writeFile(join(destination, 'payload.txt', 'operator.md'), 'mine\n'); + await rm(join(fixture.bundleRoot, 'payload.txt'), { recursive: true }); + await writeFile(join(fixture.bundleRoot, 'payload.txt'), 'flat\n'); + const collision = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }) + .catch((failure: unknown) => failure); + expect((collision as DiagnosticError).diagnostics[0]?.message).toContain('Refusing to overwrite unowned files'); + expect(await readFile(join(destination, 'payload.txt', 'operator.md'), 'utf8')).toBe('mine\n'); + } finally { + await Promise.all([ + rm(fixture.cleanupRoot, { force: true, recursive: true }), + rm(home, { force: true, recursive: true }), + ]); + } +}); + it('refuses to hash or write through a symlinked directory inside a receipt-managed Cursor install', async () => { const fixture = await createHostBundle('cursor'); const home = await mkdtemp(join(tmpdir(), 'agent-bundle-home-')); From 2e43011d1304ee835bd2de9f87628fc945266e7b Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 10:05:13 +0000 Subject: [PATCH 05/22] fix(install): empty directories are collisions; validate Claude rows before scope filter (#400) - A directory at an incoming file path is tolerated only when it holds owned files and nothing else; an empty directory is no evidence of ownership. - Every Claude inventory row for the plugin must carry a string installPath and scope before the requested-scope filter runs, so a malformed row makes the inventory unusable and --replace fails closed. --- packages/agent-bundle/src/install/install.ts | 14 +++++----- packages/agent-bundle/src/install/receipt.ts | 7 ++--- packages/agent-bundle/src/install/surface.ts | 9 ++++--- packages/agent-bundle/tests/install.test.ts | 28 ++++++++++++++++++++ 4 files changed, 45 insertions(+), 13 deletions(-) diff --git a/packages/agent-bundle/src/install/install.ts b/packages/agent-bundle/src/install/install.ts index bab07f805..91b8614fe 100644 --- a/packages/agent-bundle/src/install/install.ts +++ b/packages/agent-bundle/src/install/install.ts @@ -287,18 +287,18 @@ export const parsePublicHostInventory = ( return { detail: 'claude plugin list --json did not return an array', status: 'unavailable' }; } const rows = document.filter((candidate): candidate is Record => - isRecord(candidate) && - candidate['id'] === id && - (options.scope === undefined || candidate['scope'] === options.scope)); + isRecord(candidate) && candidate['id'] === id); const entries: PublicHostInstalledEntry[] = []; for (const row of rows) { - // A matching row we cannot read is not "not installed": replacing on it would skip the uninstall. - if (typeof row['installPath'] !== 'string') { - return { detail: `claude plugin list --json row for ${id} carries no installPath`, status: 'unavailable' }; + // Every row for this plugin must be readable before the scope filter runs: a matching row we + // cannot read is not "not installed", and replacing on it would skip the uninstall. + if (typeof row['installPath'] !== 'string' || typeof row['scope'] !== 'string') { + return { detail: `claude plugin list --json row for ${id} carries no installPath or scope`, status: 'unavailable' }; } + if (options.scope !== undefined && row['scope'] !== options.scope) continue; entries.push({ installPath: row['installPath'], - ...(typeof row['scope'] === 'string' ? { scope: row['scope'] } : {}), + scope: row['scope'], ...(typeof row['version'] === 'string' ? { version: row['version'] } : {}), }); } diff --git a/packages/agent-bundle/src/install/receipt.ts b/packages/agent-bundle/src/install/receipt.ts index d690aaebd..4b55deb9d 100644 --- a/packages/agent-bundle/src/install/receipt.ts +++ b/packages/agent-bundle/src/install/receipt.ts @@ -451,11 +451,12 @@ export const replaceInstalledTree = async (options: { } return ownedIdentities.some((metadata) => sameFile(metadata, candidate)); }; - // An existing directory at an incoming file path is fine only when everything beneath it is - // owned: those files leave as stale and the emptied directory is pruned before the rename. + // An existing directory at an incoming file path is fine only when it holds owned files and + // nothing else: those files leave as stale and the emptied directory is pruned before the + // rename. An empty directory (or one holding only empty directories) is no evidence of ownership. const isWhollyOwnedDirectory = async (file: string): Promise => { const nested = await treeInventory(join(options.destination, file)); - return nested.files.every((entry) => owned.has(`${file}/${entry}`)); + return nested.files.length > 0 && nested.files.every((entry) => owned.has(`${file}/${entry}`)); }; const collisions: string[] = []; for (const file of options.staged.inventory.files) { diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index 7615dc768..265d17b77 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -432,9 +432,12 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' }))).filter((metadata) => metadata !== undefined);', ' return ownedIdentities.some((metadata) => metadata.dev === candidate.dev && metadata.ino === candidate.ino);', ' };', - ' // An existing directory at an incoming file path is fine only when everything beneath it is owned.', - ' const isWhollyOwnedDirectory = async (file) =>', - ' (await inventory(join(destination, file))).files.every((entry) => ownedSet.has(`${file}/${entry}`));', + ' // An existing directory at an incoming file path is fine only when it holds owned files and nothing else;', + ' // an empty directory is no evidence of ownership.', + ' const isWhollyOwnedDirectory = async (file) => {', + ' const nested = (await inventory(join(destination, file))).files;', + ' return nested.length > 0 && nested.every((entry) => ownedSet.has(`${file}/${entry}`));', + ' };', ' const collisions = [];', ' for (const file of staged.inventory.files) {', ' if (ownedSet.has(file)) continue;', diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index 7b0524373..6d73bbcfc 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -183,6 +183,21 @@ it('replaces a stale same-version Claude install through uninstall + install and `plugin marketplace add ${fixture.bundleRoot}`, 'plugin install install-fixture@install-fixture-marketplace --scope user', ]); + + // A matching row without a readable scope is an unusable inventory, not "not installed". + const scopeless = recordingRunner((call) => isInventoryCall(call) + ? JSON.stringify([{ id: 'install-fixture@install-fixture-marketplace', installPath: installed, version: '1.2.3' }]) + : ''); + const error = await installBundle({ + commandRunner: scopeless.runner, + from: fixture.from, + host: 'claude', + replace: true, + scope: 'user', + }).catch((failure: unknown) => failure); + expect(error).toBeInstanceOf(DiagnosticError); + expect((error as DiagnosticError).diagnostics[0]?.message).toContain('plugin list --json was unusable'); + expect(scopeless.calls).toHaveLength(1); } finally { await rm(fixture.cleanupRoot, { force: true, recursive: true }); } @@ -515,6 +530,19 @@ it('refreshes a receipt whose inventory drifted even when the owned bytes hash e expect(await listFiles(destination)).toEqual([installReceiptFile, '.cursor-plugin/plugin.json', 'payload.txt']); expect(await readFile(join(destination, 'payload.txt'), 'utf8')).toBe('flat again\n'); + // An empty unowned directory at an incoming file path is a collision too (no ownership evidence). + await rm(join(fixture.bundleRoot, 'payload.txt')); + await writeFile(join(fixture.bundleRoot, 'payload.txt'), 'flat again\n'); + await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); + await mkdir(join(destination, 'empty-dir')); + await writeFile(join(fixture.bundleRoot, 'empty-dir'), 'now a file\n'); + const emptyCollision = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }) + .catch((failure: unknown) => failure); + expect((emptyCollision as DiagnosticError).diagnostics[0]?.message).toContain('Refusing to overwrite unowned files'); + expect((emptyCollision as DiagnosticError).diagnostics[0]?.message).toContain('empty-dir'); + await rm(join(fixture.bundleRoot, 'empty-dir')); + await rm(join(destination, 'empty-dir'), { recursive: true }); + // A directory that also holds an unowned file is a collision, not a restructure. await rm(join(fixture.bundleRoot, 'payload.txt')); await mkdir(join(fixture.bundleRoot, 'payload.txt')); From 38a03112c7b915060d88ce4581a4632352592a31 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 10:18:16 +0000 Subject: [PATCH 06/22] fix(install): legacy adoption preserves unknown files; empty descendants block dir->file (#400) - A legacy copy has no owned-file inventory, so --replace rewrites only the files the new artifact ships and leaves everything else in place (operator files, files an earlier rebuild dropped, state/); leftovers stay unowned. - A directory standing where the rebuild wants a file is tolerated only when every descendant is an owned file and no empty directory exists beneath. - install.mjs mirrors both. --- docs/diagnostics.md | 2 +- packages/agent-bundle/README.md | 9 ++-- packages/agent-bundle/src/install/receipt.ts | 53 ++++++++++++++++---- packages/agent-bundle/src/install/surface.ts | 29 ++++++++--- packages/agent-bundle/tests/install.test.ts | 28 ++++++++++- 5 files changed, 99 insertions(+), 22 deletions(-) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index ab24850a2..f8271dc9a 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -706,7 +706,7 @@ this plugin's name — a copy installed before receipts existed), or **foreign** | 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 | replaced and adopted (receipt written) | `stale` — `AB7308` warning, recovery names `--replace` | +| 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 | | Nothing installed | installed | installed | `not-installed` — `AB7307` info | diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index e87593768..fc50cd9f5 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -204,9 +204,12 @@ every host treats it differently. `agent-bundle install` and the emitted - **`--replace` (alias `--force`).** 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). Adoption writes the receipt, so - later same-version rebuilds replace automatically; a byte-identical legacy - copy under `--replace` reports `adopted` and changes no plugin file. + `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. - **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 diff --git a/packages/agent-bundle/src/install/receipt.ts b/packages/agent-bundle/src/install/receipt.ts index 4b55deb9d..fba7963ec 100644 --- a/packages/agent-bundle/src/install/receipt.ts +++ b/packages/agent-bundle/src/install/receipt.ts @@ -404,13 +404,51 @@ const pruneEmptyDirectories = async (destination: string, removedFiles: readonly } }; +/** + * 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); - return inventory.files.filter((file) => !preservedRuntimeEntries.includes(file.split('/')[0] ?? '')); + return inventory.files.filter((file) => + incoming.has(file) && !preservedRuntimeEntries.includes(file.split('/')[0] ?? '')); +}; + +/** + * True when a directory is entirely previous-installer territory: at least + * one file, every file owned, and no empty directories anywhere beneath (an + * empty directory is no evidence of ownership and would survive pruning). + */ +const isWhollyOwnedDirectory = async ( + destination: string, + relativePath: string, + owned: ReadonlySet, +): Promise => { + let files = 0; + const visit = async (directory: string): Promise => { + const entries = sortNames(await readdir(join(destination, directory))); + if (entries.length === 0) return false; + for (const name of entries) { + const relative = `${directory}/${name}`; + const metadata = await lstat(join(destination, relative)); + if (metadata.isSymbolicLink()) throw unsupportedEntry(relative); + if (metadata.isDirectory()) { + if (!await visit(relative)) return false; + continue; + } + if (!metadata.isFile() || !owned.has(relative)) return false; + files += 1; + } + return true; + }; + return await visit(relativePath) && files > 0; }; /** @@ -429,7 +467,7 @@ export const replaceInstalledTree = async (options: { 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); + const previouslyOwned = await previouslyOwnedFiles(options.destination, options.comparison, incoming); const owned = new Set(previouslyOwned); await assertRealAncestors(options.destination, previouslyOwned); await assertRealAncestors(options.destination, options.staged.inventory.files, owned); @@ -451,13 +489,8 @@ export const replaceInstalledTree = async (options: { } return ownedIdentities.some((metadata) => sameFile(metadata, candidate)); }; - // An existing directory at an incoming file path is fine only when it holds owned files and - // nothing else: those files leave as stale and the emptied directory is pruned before the - // rename. An empty directory (or one holding only empty directories) is no evidence of ownership. - const isWhollyOwnedDirectory = async (file: string): Promise => { - const nested = await treeInventory(join(options.destination, file)); - return nested.files.length > 0 && nested.files.every((entry) => owned.has(`${file}/${entry}`)); - }; + // An existing directory at an incoming file path is fine only when it is wholly owned: its + // files leave as stale and the emptied directories are pruned before the rename. const collisions: string[] = []; for (const file of options.staged.inventory.files) { if (owned.has(file)) continue; @@ -471,7 +504,7 @@ export const replaceInstalledTree = async (options: { throw error; } const tolerated = metadata.isDirectory() && !metadata.isSymbolicLink() - ? await isWhollyOwnedDirectory(file) + ? await isWhollyOwnedDirectory(options.destination, file, owned) : await isOwnedIdentity(target); if (!tolerated) collisions.push(file); } diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index 265d17b77..fba5e7c7b 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -416,9 +416,12 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { '// predecessors, and the receipt lands last as the commit marker. Unowned entries (runtime state) stay.', 'const staged = await stage();', '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);', " const owned = ownership === 'receipt'", ' ? receipt.files', - " : (await inventory(destination)).files.filter((file) => !preservedEntries.includes(file.split('/')[0]));", + " : (await inventory(destination)).files.filter((file) => incoming.has(file) && !preservedEntries.includes(file.split('/')[0]));", ' const ownedSet = new Set(owned);', ' await assertRealAncestors(destination, owned);', ' await assertRealAncestors(destination, staged.inventory.files, ownedSet);', @@ -432,11 +435,24 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' }))).filter((metadata) => metadata !== undefined);', ' return ownedIdentities.some((metadata) => metadata.dev === candidate.dev && metadata.ino === candidate.ino);', ' };', - ' // An existing directory at an incoming file path is fine only when it holds owned files and nothing else;', - ' // an empty directory is no evidence of ownership.', - ' const isWhollyOwnedDirectory = async (file) => {', - ' const nested = (await inventory(join(destination, file))).files;', - ' return nested.length > 0 && nested.every((entry) => ownedSet.has(`${file}/${entry}`));', + ' // An existing directory at an incoming file path is fine only when it is wholly owned: at least one', + ' // file, every file owned, and no empty directory anywhere beneath (no evidence of ownership).', + ' const isWhollyOwnedDirectory = async (relativePath) => {', + ' let files = 0;', + ' const visit = async (directory) => {', + ' const entries = (await readdir(join(destination, directory))).sort((left, right) => left.localeCompare(right));', + ' if (entries.length === 0) return false;', + ' for (const name of entries) {', + ' const relative = `${directory}/${name}`;', + ' const metadata = await lstat(join(destination, relative));', + ' if (metadata.isSymbolicLink()) throw unsupported(relative);', + ' if (metadata.isDirectory()) { if (!(await visit(relative))) return false; continue; }', + ' if (!metadata.isFile() || !ownedSet.has(relative)) return false;', + ' files += 1;', + ' }', + ' return true;', + ' };', + ' return (await visit(relativePath)) && files > 0;', ' };', ' const collisions = [];', ' for (const file of staged.inventory.files) {', @@ -452,7 +468,6 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' if (collisions.length > 0) {', " throw new Error(`Refusing to overwrite unowned files at ${destination}: ${collisions.join(', ')}. Move them aside manually before replacing.`);", ' }', - ' const incoming = new Set(staged.inventory.files);', ' const stale = owned.filter((file) => !incoming.has(file));', ' for (const file of stale) await rm(join(destination, file), { force: true });', ' await pruneEmptyDirectories(stale);', diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index 6d73bbcfc..626d958a2 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -444,6 +444,9 @@ it('requires --replace for a legacy pre-receipt Cursor copy and then adopts it', 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); @@ -461,7 +464,15 @@ it('requires --replace for a legacy pre-receipt Cursor copy and then adopts it', 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 readInstallReceipt(destination)).toMatchObject({ contentHash: artifact.hash, plugin: 'install-fixture' }); + 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, 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 installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); + expect(await readFile(join(destination, 'dropped-by-rebuild.txt'), 'utf8')).toBe('old artifact file\n'); } finally { await Promise.all([ rm(fixture.cleanupRoot, { force: true, recursive: true }), @@ -543,6 +554,21 @@ it('refreshes a receipt whose inventory drifted even when the owned bytes hash e await rm(join(fixture.bundleRoot, 'empty-dir')); await rm(join(destination, 'empty-dir'), { recursive: true }); + // An owned directory that also holds an unowned empty subdirectory is a collision, not a restructure. + await rm(join(fixture.bundleRoot, 'payload.txt')); + await mkdir(join(fixture.bundleRoot, 'payload.txt')); + await writeFile(join(fixture.bundleRoot, 'payload.txt', 'nested.md'), '# nested\n'); + await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); + await mkdir(join(destination, 'payload.txt', 'scratch')); + await rm(join(fixture.bundleRoot, 'payload.txt'), { recursive: true }); + await writeFile(join(fixture.bundleRoot, 'payload.txt'), 'flat\n'); + const emptyNested = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }) + .catch((failure: unknown) => failure); + expect((emptyNested as DiagnosticError).diagnostics[0]?.message).toContain('Refusing to overwrite unowned files'); + expect(await readFile(join(destination, 'payload.txt', 'nested.md'), 'utf8')).toBe('# nested\n'); + await rm(join(destination, 'payload.txt', 'scratch'), { recursive: true }); + await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); + // A directory that also holds an unowned file is a collision, not a restructure. await rm(join(fixture.bundleRoot, 'payload.txt')); await mkdir(join(fixture.bundleRoot, 'payload.txt')); From 21c8d28b4dd4a6254e81ffe9eefbd582fcbd44b3 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 10:31:44 +0000 Subject: [PATCH 07/22] fix(install): case-alias identity only, alias-aware ancestors, exclusive receipt temp (#400) - An existing entry counts as owned only by exact recorded name or as a case alias (same lowercase path) resolving to the same inode; an operator hard link under an unrelated name is a collision. - The same alias rule tolerates an owned file ancestor during file->directory restructures on case-insensitive filesystems. - Receipt temp files are created exclusively (`wx`) with a random name so no existing file or link is followed or overwritten. - install.mjs mirrors all three. --- packages/agent-bundle/src/install/receipt.ts | 71 +++++++++++++------- packages/agent-bundle/src/install/surface.ts | 50 +++++++++----- packages/agent-bundle/tests/install.test.ts | 12 +++- 3 files changed, 88 insertions(+), 45 deletions(-) diff --git a/packages/agent-bundle/src/install/receipt.ts b/packages/agent-bundle/src/install/receipt.ts index fba7963ec..63466d153 100644 --- a/packages/agent-bundle/src/install/receipt.ts +++ b/packages/agent-bundle/src/install/receipt.ts @@ -1,10 +1,11 @@ -import { createHash } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import type { Stats } from 'node:fs'; import { cp, lstat, mkdir, mkdtemp, + open, readFile, readdir, rename, @@ -123,6 +124,32 @@ export const treeInventory = async (root: string): Promise => { return Object.freeze({ files: Object.freeze(files), hash: hash.digest('hex') }); }; +/** + * Whether an existing entry at `relativePath` is one of the previously owned + * files: the exact recorded name, or — on case-insensitive filesystems — a + * case alias of a recorded name that resolves to the same inode. Inode + * equality alone is not enough: an operator hard link to an owned file under + * an unrelated name is not ours. + */ +const isOwnedEntry = async ( + root: string, + owned: ReadonlySet, + relativePath: string, + metadata: Stats, +): Promise => { + if (owned.has(relativePath)) return true; + const alias = relativePath.toLowerCase(); + for (const candidate of owned) { + if (candidate.toLowerCase() !== alias) continue; + try { + if (sameFile(await lstat(join(root, candidate)), metadata)) return true; + } catch (error) { + if (!isErrno(error, 'ENOENT')) throw error; + } + } + return false; +}; + /** * Every directory on the way to a listed path must be a real directory: a * symlinked ancestor would let a leaf-only check read, hash, delete, or write @@ -145,7 +172,10 @@ const assertRealAncestors = async ( try { const metadata = await lstat(join(root, directory)); if (metadata.isSymbolicLink()) throw unsupportedEntry(directory); - if (!metadata.isDirectory() && !(metadata.isFile() && ownedFiles.has(directory))) { + if ( + !metadata.isDirectory() && + !(metadata.isFile() && await isOwnedEntry(root, ownedFiles, directory, metadata)) + ) { throw unsupportedEntry(directory); } } catch (error) { @@ -248,11 +278,17 @@ export const createInstallReceipt = (options: { const receiptDocument = (receipt: InstallReceipt): string => `${stableJson(receipt)}\n`; -/** Lands a receipt at a plugin root atomically (sibling temp file + rename). */ +/** + * Lands a receipt at a plugin root atomically: an exclusively created, + * randomly named sibling (`wx` never follows an existing link or overwrites + * a file) renamed into place. + */ export const writeInstallReceipt = async (destination: string, receipt: InstallReceipt): Promise => { - const temporary = join(destination, `${installReceiptFile}.${process.pid}.tmp`); - await writeFile(temporary, receiptDocument(receipt), 'utf8'); + const temporary = join(destination, `${installReceiptFile}.${randomUUID()}.tmp`); + const handle = await open(temporary, 'wx'); try { + await handle.writeFile(receiptDocument(receipt), 'utf8'); + await handle.close(); await rename(temporary, join(destination, installReceiptFile)); } finally { await rm(temporary, { force: true }); @@ -471,26 +507,9 @@ export const replaceInstalledTree = async (options: { const owned = new Set(previouslyOwned); await assertRealAncestors(options.destination, previouslyOwned); await assertRealAncestors(options.destination, options.staged.inventory.files, owned); - // On case-insensitive filesystems a case-only rename of an owned path resolves to the same - // inode, so ownership is decided by file identity, not by name alone. - let ownedIdentities: readonly Stats[] | undefined; - const isOwnedIdentity = async (path: string): Promise => { - const candidate = await lstat(path); - if (ownedIdentities === undefined) { - const identities: Stats[] = []; - for (const file of previouslyOwned) { - try { - identities.push(await lstat(join(options.destination, file))); - } catch (error) { - if (!isErrno(error, 'ENOENT')) throw error; - } - } - ownedIdentities = identities; - } - return ownedIdentities.some((metadata) => sameFile(metadata, candidate)); - }; - // An existing directory at an incoming file path is fine only when it is wholly owned: its - // files leave as stale and the emptied directories are pruned before the rename. + // An existing entry at an incoming path is fine when it is the owned file itself (exact name, or + // a case alias on case-insensitive filesystems) or a wholly owned directory whose files leave as + // stale and whose emptied directories are pruned before the rename. const collisions: string[] = []; for (const file of options.staged.inventory.files) { if (owned.has(file)) continue; @@ -505,7 +524,7 @@ export const replaceInstalledTree = async (options: { } const tolerated = metadata.isDirectory() && !metadata.isSymbolicLink() ? await isWhollyOwnedDirectory(options.destination, file, owned) - : await isOwnedIdentity(target); + : metadata.isFile() && await isOwnedEntry(options.destination, owned, file, metadata); if (!tolerated) collisions.push(file); } if (collisions.length > 0) { diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index fba5e7c7b..62358631d 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -173,8 +173,8 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { const version = JSON.stringify(model.metadata.version); return [ '#!/usr/bin/env node', - "import { createHash } from 'node:crypto';", - "import { cp, lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, rmdir, writeFile } from 'node:fs/promises';", + "import { createHash, randomUUID } from 'node:crypto';", + "import { cp, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, rmdir, writeFile } from 'node:fs/promises';", "import { homedir } from 'node:os';", "import { basename, dirname, join, resolve } from 'node:path';", "import { fileURLToPath } from 'node:url';", @@ -238,6 +238,21 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { " return { files, hash: hash.digest('hex') };", '};', '', + '// An existing entry is one of ours when its exact name is owned, or when it is a case alias of an owned', + '// name resolving to the same inode (case-insensitive filesystems). Inode equality alone is not enough.', + 'const isOwnedEntry = async (root, owned, relative, metadata) => {', + ' if (owned.has(relative)) return true;', + ' const alias = relative.toLowerCase();', + ' for (const candidate of owned) {', + ' if (candidate.toLowerCase() !== alias) continue;', + ' try {', + ' const recorded = await lstat(join(root, candidate));', + ' if (recorded.dev === metadata.dev && recorded.ino === metadata.ino) return true;', + " } catch (error) { if (error?.code !== 'ENOENT') throw error; }", + ' }', + ' return false;', + '};', + '', '// Every directory on the way to a listed path must be a real directory (no symlinked ancestors);', '// an owned regular file that a rebuild turns into a directory leaves as stale first, so it is tolerated.', 'const assertRealAncestors = async (root, files, ownedFiles = new Set()) => {', @@ -250,7 +265,9 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' try {', ' const metadata = await lstat(join(root, directory));', ' if (metadata.isSymbolicLink()) throw unsupported(directory);', - ' if (!metadata.isDirectory() && !(metadata.isFile() && ownedFiles.has(directory))) throw unsupported(directory);', + ' if (!metadata.isDirectory() && !(metadata.isFile() && await isOwnedEntry(root, ownedFiles, directory, metadata))) {', + ' throw unsupported(directory);', + ' }', " } catch (error) { if (error?.code !== 'ENOENT') throw error; }", ' }', ' directory = dirname(directory);', @@ -385,10 +402,17 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' (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.', - ' const temporary = join(destination, `${receiptFile}.${process.pid}.tmp`);', - " await writeFile(temporary, receiptFor(artifact), 'utf8');", - ' await rename(temporary, join(destination, receiptFile));', + ' // Byte-identical pre-receipt copy: adoption only writes the receipt, through an exclusively', + ' // created random sibling so no existing file or link is followed or overwritten.', + ' const temporary = join(destination, `${receiptFile}.${randomUUID()}.tmp`);', + " const handle = await open(temporary, 'wx');", + ' try {', + " await handle.writeFile(receiptFor(artifact), 'utf8');", + ' await handle.close();', + ' await rename(temporary, join(destination, receiptFile));', + ' } finally {', + ' await rm(temporary, { force: true });', + ' }', ' console.log(`Adopted ${pluginName}@${pluginVersion} at ${destination} (content ${short(artifact.hash)})`);', ' process.exit(0);', ' }', @@ -425,16 +449,6 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' const ownedSet = new Set(owned);', ' await assertRealAncestors(destination, owned);', ' await assertRealAncestors(destination, staged.inventory.files, ownedSet);', - ' // Case-insensitive filesystems: a case-only rename of an owned path is the same inode, not a collision.', - ' let ownedIdentities;', - ' const isOwnedIdentity = async (path) => {', - ' const candidate = await lstat(path);', - ' ownedIdentities ??= (await Promise.all(owned.map(async (file) => {', - ' try { return await lstat(join(destination, file)); }', - " catch (error) { if (error?.code === 'ENOENT') return undefined; throw error; }", - ' }))).filter((metadata) => metadata !== undefined);', - ' return ownedIdentities.some((metadata) => metadata.dev === candidate.dev && metadata.ino === candidate.ino);', - ' };', ' // An existing directory at an incoming file path is fine only when it is wholly owned: at least one', ' // file, every file owned, and no empty directory anywhere beneath (no evidence of ownership).', ' const isWhollyOwnedDirectory = async (relativePath) => {', @@ -462,7 +476,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { " try { metadata = await lstat(target); } catch (error) { if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') continue; throw error; }", ' const tolerated = metadata.isDirectory() && !metadata.isSymbolicLink()', ' ? await isWhollyOwnedDirectory(file)', - ' : await isOwnedIdentity(target);', + ' : metadata.isFile() && await isOwnedEntry(destination, ownedSet, file, metadata);', ' if (!tolerated) collisions.push(file);', ' }', ' if (collisions.length > 0) {', diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index 626d958a2..bb5e850c0 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -1,4 +1,4 @@ -import { access, cp, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { access, cp, link, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; @@ -541,6 +541,16 @@ it('refreshes a receipt whose inventory drifted even when the owned bytes hash e expect(await listFiles(destination)).toEqual([installReceiptFile, '.cursor-plugin/plugin.json', 'payload.txt']); expect(await readFile(join(destination, 'payload.txt'), 'utf8')).toBe('flat again\n'); + // An operator hard link to an owned file under an unrelated name is not ours: incoming path → collision. + await link(join(destination, 'payload.txt'), join(destination, 'hard-linked.txt')); + await writeFile(join(fixture.bundleRoot, 'hard-linked.txt'), 'from the artifact\n'); + const hardLink = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }) + .catch((failure: unknown) => failure); + expect((hardLink as DiagnosticError).diagnostics[0]?.message).toContain('Refusing to overwrite unowned files'); + expect((hardLink as DiagnosticError).diagnostics[0]?.message).toContain('hard-linked.txt'); + await rm(join(fixture.bundleRoot, 'hard-linked.txt')); + await rm(join(destination, 'hard-linked.txt')); + // An empty unowned directory at an incoming file path is a collision too (no ownership evidence). await rm(join(fixture.bundleRoot, 'payload.txt')); await writeFile(join(fixture.bundleRoot, 'payload.txt'), 'flat again\n'); From 3ef368e3c58c0b163d3183e11a3dcda4e2d840f5 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 10:45:42 +0000 Subject: [PATCH 08/22] fix(install): prove case aliases via realpath; refuse symlinked receipts (#400) - An existing entry is the owned file only by exact recorded name or when both spellings realpath to the same on-disk path; inode equality is no longer used, so distinct hard-linked case variants and unrelated hard links are collisions. Directory descendants use the same alias rule. - A receipt that is a symbolic link is refused as deletion authority, both when read and when the tree is inventoried. - install.mjs mirrors both. --- packages/agent-bundle/src/install/receipt.ts | 39 ++++++++++++++------ packages/agent-bundle/src/install/surface.ts | 31 ++++++++++------ packages/agent-bundle/tests/install.test.ts | 26 ++++++++++++- 3 files changed, 71 insertions(+), 25 deletions(-) diff --git a/packages/agent-bundle/src/install/receipt.ts b/packages/agent-bundle/src/install/receipt.ts index 63466d153..2f1f9731c 100644 --- a/packages/agent-bundle/src/install/receipt.ts +++ b/packages/agent-bundle/src/install/receipt.ts @@ -8,6 +8,7 @@ import { open, readFile, readdir, + realpath, rename, rm, rmdir, @@ -17,7 +18,7 @@ import { basename, dirname, join } from 'node:path'; import { stableJson } from '../core/digest.ts'; import { isErrno } from '../core/errors.ts'; -import { exists, sameFile } from '../core/paths.ts'; +import { exists } from '../core/paths.ts'; /** * Host-agnostic install ownership core shared by `agent-bundle install`, @@ -118,7 +119,11 @@ export const treeInventory = async (root: string): Promise => { hashEntry(hash, relativePath, await readFile(path)); }; for (const name of sortNames(await readdir(root))) { - if (name === installReceiptFile) continue; + if (name === installReceiptFile) { + // The receipt is deletion authority; it is skipped from the hash but may never be a link. + if ((await lstat(join(root, name))).isSymbolicLink()) throw unsupportedEntry(name); + continue; + } await visit(name); } return Object.freeze({ files: Object.freeze(files), hash: hash.digest('hex') }); @@ -127,22 +132,26 @@ export const treeInventory = async (root: string): Promise => { /** * Whether an existing entry at `relativePath` is one of the previously owned * files: the exact recorded name, or — on case-insensitive filesystems — a - * case alias of a recorded name that resolves to the same inode. Inode - * equality alone is not enough: an operator hard link to an owned file under - * an unrelated name is not ours. + * case alias of a recorded name. An alias is proven by the filesystem itself: + * both spellings canonicalise (`realpath`, on-disk casing) to the same path. + * Inode equality is deliberately not used: two distinct entries `Foo` and + * `foo` hard-linked together on a case-sensitive filesystem are not aliases, + * and an operator hard link under an unrelated name is not ours either. + * Ancestors have already been proven symlink-free before this runs. */ const isOwnedEntry = async ( root: string, owned: ReadonlySet, relativePath: string, - metadata: Stats, ): Promise => { if (owned.has(relativePath)) return true; const alias = relativePath.toLowerCase(); + let canonical: string | undefined; for (const candidate of owned) { if (candidate.toLowerCase() !== alias) continue; try { - if (sameFile(await lstat(join(root, candidate)), metadata)) return true; + canonical ??= await realpath(join(root, relativePath)); + if (await realpath(join(root, candidate)) === canonical) return true; } catch (error) { if (!isErrno(error, 'ENOENT')) throw error; } @@ -174,7 +183,7 @@ const assertRealAncestors = async ( if (metadata.isSymbolicLink()) throw unsupportedEntry(directory); if ( !metadata.isDirectory() && - !(metadata.isFile() && await isOwnedEntry(root, ownedFiles, directory, metadata)) + !(metadata.isFile() && await isOwnedEntry(root, ownedFiles, directory)) ) { throw unsupportedEntry(directory); } @@ -227,11 +236,17 @@ export const isReceiptPath = (value: unknown): value is string => const isReceiptFileList = (value: unknown): value is readonly string[] => Array.isArray(value) && value.every(isReceiptPath); -/** Reads the receipt at a plugin root; malformed or unsafe receipts read as absent. */ +/** + * Reads the receipt at a plugin root; malformed or unsafe receipts read as + * absent. A receipt that is a symbolic link is refused outright: it would let + * another file supply the owned-file list that drives deletions. + */ export const readInstallReceipt = async (destination: string): Promise => { + const path = join(destination, installReceiptFile); let value: unknown; try { - value = JSON.parse(await readFile(join(destination, installReceiptFile), 'utf8')) as unknown; + if ((await lstat(path)).isSymbolicLink()) throw unsupportedEntry(installReceiptFile); + value = JSON.parse(await readFile(path, 'utf8')) as unknown; } catch (error) { if (isErrno(error, 'ENOENT') || error instanceof SyntaxError) return undefined; throw error; @@ -479,7 +494,7 @@ const isWhollyOwnedDirectory = async ( if (!await visit(relative)) return false; continue; } - if (!metadata.isFile() || !owned.has(relative)) return false; + if (!metadata.isFile() || !await isOwnedEntry(destination, owned, relative)) return false; files += 1; } return true; @@ -524,7 +539,7 @@ export const replaceInstalledTree = async (options: { } const tolerated = metadata.isDirectory() && !metadata.isSymbolicLink() ? await isWhollyOwnedDirectory(options.destination, file, owned) - : metadata.isFile() && await isOwnedEntry(options.destination, owned, file, metadata); + : metadata.isFile() && await isOwnedEntry(options.destination, owned, file); if (!tolerated) collisions.push(file); } if (collisions.length > 0) { diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index 62358631d..d7d9460d3 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -174,7 +174,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { return [ '#!/usr/bin/env node', "import { createHash, randomUUID } from 'node:crypto';", - "import { cp, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, rmdir, writeFile } from 'node:fs/promises';", + "import { cp, lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, rmdir, writeFile } from 'node:fs/promises';", "import { homedir } from 'node:os';", "import { basename, dirname, join, resolve } from 'node:path';", "import { fileURLToPath } from 'node:url';", @@ -232,22 +232,27 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' hashEntry(hash, relative, await readFile(absolute));', ' };', ' for (const entry of (await readdir(root)).sort((left, right) => left.localeCompare(right))) {', - ' if (entry === receiptFile) continue;', + ' if (entry === receiptFile) {', + ' // The receipt is deletion authority: skipped from the hash, never allowed to be a link.', + ' if ((await lstat(join(root, entry))).isSymbolicLink()) throw unsupported(entry);', + ' continue;', + ' }', ' await visit(entry);', ' }', " return { files, hash: hash.digest('hex') };", '};', '', - '// An existing entry is one of ours when its exact name is owned, or when it is a case alias of an owned', - '// name resolving to the same inode (case-insensitive filesystems). Inode equality alone is not enough.', - 'const isOwnedEntry = async (root, owned, relative, metadata) => {', + '// An existing entry is one of ours when its exact name is owned, or when the filesystem proves it a case', + '// alias of an owned name (both spellings realpath to the same on-disk path). Inode equality is not used.', + 'const isOwnedEntry = async (root, owned, relative) => {', ' if (owned.has(relative)) return true;', ' const alias = relative.toLowerCase();', + ' let canonical;', ' for (const candidate of owned) {', ' if (candidate.toLowerCase() !== alias) continue;', ' try {', - ' const recorded = await lstat(join(root, candidate));', - ' if (recorded.dev === metadata.dev && recorded.ino === metadata.ino) return true;', + ' canonical ??= await realpath(join(root, relative));', + ' if ((await realpath(join(root, candidate))) === canonical) return true;', " } catch (error) { if (error?.code !== 'ENOENT') throw error; }", ' }', ' return false;', @@ -265,7 +270,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' try {', ' const metadata = await lstat(join(root, directory));', ' if (metadata.isSymbolicLink()) throw unsupported(directory);', - ' if (!metadata.isDirectory() && !(metadata.isFile() && await isOwnedEntry(root, ownedFiles, directory, metadata))) {', + ' if (!metadata.isDirectory() && !(metadata.isFile() && await isOwnedEntry(root, ownedFiles, directory))) {', ' throw unsupported(directory);', ' }', " } catch (error) { if (error?.code !== 'ENOENT') throw error; }", @@ -296,8 +301,10 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { '', 'const readReceipt = async (root) => {', ' let value;', - " try { value = JSON.parse(await readFile(join(root, receiptFile), 'utf8')); }", - " catch (error) { if (error?.code === 'ENOENT' || error instanceof SyntaxError) return undefined; throw error; }", + ' try {', + ' if ((await lstat(join(root, receiptFile))).isSymbolicLink()) throw unsupported(receiptFile);', + " value = JSON.parse(await readFile(join(root, receiptFile), '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;", ' // Same shape check as the core reader: a receipt missing any field reads as absent.', " if (value.format !== receiptFormat || typeof value.plugin !== 'string' || typeof value.version !== 'string' ||", @@ -461,7 +468,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' const metadata = await lstat(join(destination, relative));', ' if (metadata.isSymbolicLink()) throw unsupported(relative);', ' if (metadata.isDirectory()) { if (!(await visit(relative))) return false; continue; }', - ' if (!metadata.isFile() || !ownedSet.has(relative)) return false;', + ' if (!metadata.isFile() || !(await isOwnedEntry(destination, ownedSet, relative))) return false;', ' files += 1;', ' }', ' return true;', @@ -476,7 +483,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { " try { metadata = await lstat(target); } catch (error) { if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') continue; throw error; }", ' const tolerated = metadata.isDirectory() && !metadata.isSymbolicLink()', ' ? await isWhollyOwnedDirectory(file)', - ' : metadata.isFile() && await isOwnedEntry(destination, ownedSet, file, metadata);', + ' : metadata.isFile() && await isOwnedEntry(destination, ownedSet, file);', ' if (!tolerated) collisions.push(file);', ' }', ' if (collisions.length > 0) {', diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index bb5e850c0..814ca80c4 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -621,9 +621,33 @@ it('refuses to hash or write through a symlinked directory inside a receipt-mana expect(await readdir(elsewhere)).toEqual([]); expect(await readFile(join(destination, 'payload.txt'), 'utf8')).toBe('payload\n'); - // An owned path whose ancestor became a symlink (development installs re-point top-level directories). await rm(join(destination, 'skills')); await rm(join(fixture.bundleRoot, 'skills'), { recursive: true }); + + // A symlinked receipt is never deletion authority. + const receiptPath = join(destination, installReceiptFile); + await rm(receiptPath); + await writeFile(join(elsewhere, 'receipt.json'), JSON.stringify({ + contentHash: 'abc', + files: ['payload.txt'], + format: 'agent-bundle-install-receipt/1', + host: 'cursor', + installedAt: '2026-09-03T00:00:00.000Z', + plugin: 'install-fixture', + version: '1.2.3', + })); + await symlink(join(elsewhere, 'receipt.json'), receiptPath); + const linkedReceipt = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }) + .catch((failure: unknown) => failure); + expect((linkedReceipt as DiagnosticError).diagnostics[0]?.message).toContain( + `Refusing unsupported filesystem entry "${installReceiptFile}"`, + ); + await rm(join(elsewhere, 'receipt.json')); + await rm(destination, { force: true, recursive: true }); + expect(await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' })) + .toMatchObject({ state: 'installed' }); + + // An owned path whose ancestor became a symlink (development installs re-point top-level directories). await cp(join(destination, '.cursor-plugin'), join(destination, '.real-manifest'), { recursive: true }); await rm(join(destination, '.cursor-plugin'), { recursive: true }); await symlink(join(destination, '.real-manifest'), join(destination, '.cursor-plugin')); From 5118dcc681f5953ed71e711064a549dc3522d761 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 10:58:45 +0000 Subject: [PATCH 09/22] fix(install): public-host version collisions fail closed; foreign beats byte equality (#400) - Claude/Codex: an installed copy at a different version is an AB7005 version collision without --replace (no host command runs after the inventory read), matching the documented matrix. - A destination whose receipt names another plugin is foreign even when its bytes match the artifact; the comparison verdict distinguishes "same content, different version". - install.mjs mirrors both. --- packages/agent-bundle/src/install/install.ts | 15 +++++++++++++ packages/agent-bundle/src/install/receipt.ts | 22 +++++++++---------- packages/agent-bundle/src/install/surface.ts | 22 +++++++++++-------- packages/agent-bundle/tests/install.test.ts | 23 ++++++++++++-------- 4 files changed, 53 insertions(+), 29 deletions(-) diff --git a/packages/agent-bundle/src/install/install.ts b/packages/agent-bundle/src/install/install.ts index 91b8614fe..0d74e2bb7 100644 --- a/packages/agent-bundle/src/install/install.ts +++ b/packages/agent-bundle/src/install/install.ts @@ -407,6 +407,21 @@ const installPublicCli = async ( if (installed !== undefined && sameVersion && installed.hash === artifact.hash) { return { ...base, destination: entry.installPath, state: 'already-installed' }; } + if (entry.version !== undefined && !sameVersion && options.replace !== true) { + const detail = describeContentComparison(identity.plugin, identity.version, { + artifactContentHash: artifact.hash, + installedContentHash: installed?.hash ?? 'unknown', + installedName: identity.plugin, + installedVersion: entry.version, + status: 'version-mismatch', + }); + throw failure( + 'AB7005', + `Refusing version collision for ${host} ${id} at ${entry.installPath}: ${detail}. ` + + 'Re-run with --replace to replace the installed version.', + host, + ); + } const contentDrift = installed !== undefined && sameVersion && installed.hash !== artifact.hash; if (options.replace === true || contentDrift) { await runHostCommand(runner, identity, host, publicHostUninstallArguments(host, id, scope), 'removal'); diff --git a/packages/agent-bundle/src/install/receipt.ts b/packages/agent-bundle/src/install/receipt.ts index 2f1f9731c..15b43646b 100644 --- a/packages/agent-bundle/src/install/receipt.ts +++ b/packages/agent-bundle/src/install/receipt.ts @@ -354,10 +354,11 @@ export const compareInstalledTree = async (options: { (receipt !== undefined && receipt.files.length === options.artifact.files.length && receipt.files.every((file, index) => file === options.artifact.files[index])); - const status: InstalledTreeStatus = installedContentHash === options.artifact.hash && inventoryMatches - ? 'current' - : ownership === 'foreign' - ? 'foreign' + // Foreign ownership wins over byte equality: a directory that is not ours is never "current". + const status: InstalledTreeStatus = ownership === 'foreign' + ? 'foreign' + : installedContentHash === options.artifact.hash && inventoryMatches + ? 'current' : installedVersion !== undefined && installedVersion !== options.version ? 'version-mismatch' : 'stale'; @@ -385,13 +386,12 @@ export const describeContentComparison = ( >, ): string => { const installedVersion = comparison.installedVersion ?? 'unknown version'; - const verdict = comparison.status === 'current' - ? 'same content' - : comparison.installedVersion === undefined - ? 'different content, installed version unknown' - : comparison.installedVersion === version - ? 'same version, different content' - : 'different version'; + const sameContent = comparison.installedContentHash === comparison.artifactContentHash; + const verdict = comparison.installedVersion === undefined + ? sameContent ? 'same content, installed version unknown' : 'different content, installed version unknown' + : comparison.installedVersion === version + ? sameContent ? 'same content' : 'same version, different content' + : sameContent ? 'same content, different version' : 'different version'; return `installed ${comparison.installedName ?? plugin}@${installedVersion} ` + `content ${shortHash(comparison.installedContentHash)} vs artifact ${plugin}@${version} ` + `content ${shortHash(comparison.artifactContentHash)} (${verdict})`; diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index d7d9460d3..d1c1a0d69 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -404,6 +404,19 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { '}', "const installedVersion = manifest?.version ?? (ownership === 'receipt' ? receipt.version : undefined);", "const installedName = manifest?.name ?? (ownership === 'receipt' ? receipt.plugin : pluginName);", + 'const sameContent = installedHash === artifact.hash;', + 'const verdict = installedVersion === undefined', + " ? (sameContent ? 'same content, installed version unknown' : 'different content, installed version unknown')", + ' : installedVersion === pluginVersion', + " ? (sameContent ? 'same content' : 'same version, different content')", + " : (sameContent ? 'same content, different version' : 'different version');", + "const detail = `installed ${installedName}@${installedVersion ?? 'unknown version'} content ${short(installedHash)} ` +", + ' `vs artifact ${pluginName}@${pluginVersion} content ${short(artifact.hash)} (${verdict})`;', + '// Foreign ownership wins over byte equality: a directory that is not ours is never current.', + "if (ownership === 'foreign') {", + ' throw new Error(`Refusing foreign install at ${destination}: ${detail}; the directory is not an agent-bundle install of ` +', + ' `${pluginName}, so --replace does not apply. Remove it manually if it is stale.`);', + '}', '// A receipt-managed copy is current only when its recorded inventory matches the artifact too.', "const inventoryMatches = ownership !== 'receipt' ||", ' (receipt.files.length === artifact.files.length && receipt.files.every((file, index) => file === artifact.files[index]));', @@ -426,15 +439,6 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' console.log(`Already installed ${pluginName}@${pluginVersion} at ${destination} (content ${short(artifact.hash)})`);', ' process.exit(0);', '}', - 'const verdict = installedVersion === undefined', - " ? 'different content, installed version unknown'", - " : installedVersion === pluginVersion ? 'same version, different content' : 'different version';", - "const detail = `installed ${installedName}@${installedVersion ?? 'unknown version'} content ${short(installedHash)} ` +", - ' `vs artifact ${pluginName}@${pluginVersion} content ${short(artifact.hash)} (${verdict})`;', - "if (ownership === 'foreign') {", - ' throw new Error(`Refusing foreign install at ${destination}: ${detail}; the directory is not an agent-bundle install of ` +', - ' `${pluginName}, so --replace does not apply. Remove it manually if it is stale.`);', - '}', '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.`);', '}', diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index 814ca80c4..baf8cd219 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -220,13 +220,14 @@ it('honours --replace for Codex through remove + add and fails closed without a scope: 'user' as const, }; try { - const plain = await installBundle(options); - expect(plain).toMatchObject({ host: 'codex', state: 'installed' }); - expect(calls.map((call) => call.args.join(' '))).toEqual([ - 'plugin list --json', - `plugin marketplace add ${fixture.bundleRoot}`, - 'plugin add install-fixture@install-fixture-marketplace', - ]); + // A different installed version is a collision without --replace: nothing runs after the inventory read. + const plain = await installBundle(options).catch((failure: unknown) => failure); + expect(plain).toBeInstanceOf(DiagnosticError); + expect((plain as DiagnosticError).diagnostics[0]).toMatchObject({ code: 'AB7005', target: 'codex' }); + expect((plain as DiagnosticError).diagnostics[0]?.message).toContain('Refusing version collision'); + expect((plain as DiagnosticError).diagnostics[0]?.message).toContain('installed install-fixture@1.0.0'); + expect((plain as DiagnosticError).diagnostics[0]?.message).toContain('(same content, different version)'); + expect(calls.map((call) => call.args.join(' '))).toEqual(['plugin list --json']); calls.length = 0; const forced = await installBundle({ ...options, replace: true }); @@ -722,12 +723,16 @@ it('refuses foreign Cursor directories even with --replace and gates version col } expect(await readFile(join(destination, 'payload.txt'), 'utf8')).toBe('someone else\n'); - // A different plugin's receipt-managed install at this path is foreign as well. + // A different plugin's receipt-managed install at this path is foreign as well, even byte-identical. await rm(destination, { force: true, recursive: true }); await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); - await writeJson(join(destination, '.cursor-plugin/plugin.json'), { name: 'other-plugin', version: '1.2.3' }); const receipt = JSON.parse(await readFile(join(destination, installReceiptFile), 'utf8')) as Record; await writeJson(join(destination, installReceiptFile), { ...receipt, plugin: 'other-plugin' }); + const identicalForeign = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }) + .catch((failure: unknown) => failure); + expect((identicalForeign as DiagnosticError).diagnostics[0]?.message).toContain('Refusing foreign install'); + expect((identicalForeign as DiagnosticError).diagnostics[0]?.message).toContain('(same content)'); + await writeJson(join(destination, '.cursor-plugin/plugin.json'), { name: 'other-plugin', version: '1.2.3' }); const otherError = await installBundle({ from: fixture.from, home, host: 'cursor', replace: true, scope: 'user' }) .catch((failure: unknown) => failure); expect((otherError as DiagnosticError).diagnostics[0]?.message).toContain('Refusing foreign install'); From 726cf9bc7fe1f4abe14a2296b0d960e8c1c11b0f Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 11:11:00 +0000 Subject: [PATCH 10/22] fix(install): uncomparable host copies fail closed; alias-aware legacy adoption; manifest-less doctor foreign (#400) - A host-reported cache copy that cannot be inventoried is AB7004 without --replace instead of passing as "no drift"; --replace reinstalls it. - Legacy adoption intersects the existing tree with the artifact through the case-alias check, so Foo/a vs foo/a adopts on case-insensitive filesystems. - Doctor runs the ownership comparison on a manifest-less Cursor destination: a receipt naming the plugin still owns it, anything else is foreign (AB7321). - install.mjs mirrors the alias-aware intersection. --- packages/agent-bundle/src/install/doctor.ts | 30 +++++++------------- packages/agent-bundle/src/install/install.ts | 11 ++++++- packages/agent-bundle/src/install/receipt.ts | 9 ++++-- packages/agent-bundle/src/install/surface.ts | 13 +++++++-- packages/agent-bundle/tests/doctor.test.ts | 10 +++++-- packages/agent-bundle/tests/install.test.ts | 14 +++++++++ 6 files changed, 58 insertions(+), 29 deletions(-) diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index 600329dfc..3e5032ae3 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -993,30 +993,20 @@ const cursorBundle = async ( }), }; } + // A destination without a loader manifest is not corrupt-but-ours: ownership decides (a receipt + // naming this plugin still owns it; anything else is foreign). const installed = await readInstalledManifest(destination); - if (installed === undefined) { - return { - diagnostics: freezeDiagnostics([diagnostic( - 'AB7310', - `Cursor destination ${JSON.stringify(destination)} has no valid loader manifest.`, - 'Remove the corrupt copy manually and reinstall the Cursor plugin.', - 'error', - 'cursor', - )]), - finding: Object.freeze({ - ...base, - comparison: Object.freeze({ artifactContentHash: artifact.hash, status: 'unknown' as const }), - state: 'corrupt', - }), - }; - } const comparison = await compareInstalledTree({ artifact, destination, - installedManifest: { - name: installed.name, - ...(installed.version === undefined ? {} : { version: installed.version }), - }, + ...(installed === undefined + ? {} + : { + installedManifest: { + name: installed.name, + ...(installed.version === undefined ? {} : { version: installed.version }), + }, + }), plugin: identity.name, version: identity.version, }); diff --git a/packages/agent-bundle/src/install/install.ts b/packages/agent-bundle/src/install/install.ts index 0d74e2bb7..c42d4de5b 100644 --- a/packages/agent-bundle/src/install/install.ts +++ b/packages/agent-bundle/src/install/install.ts @@ -400,7 +400,16 @@ const installPublicCli = async ( let installed: TreeInventory | undefined; try { installed = await treeInventory(entry.installPath); - } catch { + } catch (error) { + // The host says a copy is installed but it cannot be compared: never let that pass as "no drift". + if (options.replace !== true) { + throw failure( + 'AB7004', + `${host} installed copy of ${id} at ${JSON.stringify(entry.installPath)} could not be compared: ` + + `${errorMessage(error)}. Re-run with --replace to reinstall it.`, + host, + ); + } installed = undefined; } const sameVersion = entry.version === identity.version; diff --git a/packages/agent-bundle/src/install/receipt.ts b/packages/agent-bundle/src/install/receipt.ts index 15b43646b..4331969e2 100644 --- a/packages/agent-bundle/src/install/receipt.ts +++ b/packages/agent-bundle/src/install/receipt.ts @@ -468,8 +468,13 @@ const previouslyOwnedFiles = async ( ): Promise => { if (comparison.ownership === 'receipt' && comparison.receipt !== undefined) return comparison.receipt.files; const inventory = await treeInventory(destination); - return inventory.files.filter((file) => - incoming.has(file) && !preservedRuntimeEntries.includes(file.split('/')[0] ?? '')); + const owned: string[] = []; + for (const file of inventory.files) { + if (preservedRuntimeEntries.includes(file.split('/')[0] ?? '')) continue; + // 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; }; /** diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index d1c1a0d69..6ca6dbb95 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -454,9 +454,16 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' // 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);', - " const owned = ownership === 'receipt'", - ' ? receipt.files', - " : (await inventory(destination)).files.filter((file) => incoming.has(file) && !preservedEntries.includes(file.split('/')[0]));", + ' let owned;', + " if (ownership === 'receipt') {", + ' owned = receipt.files;', + ' } else {', + ' owned = [];', + ' for (const file of (await inventory(destination)).files) {', + " if (preservedEntries.includes(file.split('/')[0])) continue;", + ' if (await isOwnedEntry(destination, incoming, file)) owned.push(file);', + ' }', + ' }', ' const ownedSet = new Set(owned);', ' await assertRealAncestors(destination, owned);', ' await assertRealAncestors(destination, staged.inventory.files, ownedSet);', diff --git a/packages/agent-bundle/tests/doctor.test.ts b/packages/agent-bundle/tests/doctor.test.ts index 854973bd9..52c17cd25 100644 --- a/packages/agent-bundle/tests/doctor.test.ts +++ b/packages/agent-bundle/tests/doctor.test.ts @@ -993,7 +993,7 @@ it('turns a symlink inside a Cursor bundle into a corrupt finding', async () => } }); -it('reports a Cursor destination without a valid manifest as corrupt', async () => { +it('reports a Cursor destination without a valid manifest as a foreign install', async () => { const fixture = await temporaryDoctor(); try { const bundle = await createBundle(fixture.root, 'cursor'); @@ -1006,10 +1006,14 @@ it('reports a Cursor destination without a valid manifest as corrupt', async () home: fixture.home, hosts: ['cursor'], }); - expect(hostReport(report, 'cursor').bundle?.state).toBe('corrupt'); + expect(hostReport(report, 'cursor').bundle).toMatchObject({ + comparison: { ownership: 'foreign', status: 'foreign' }, + state: 'conflicted', + }); expect(report.diagnostics).toEqual(expect.arrayContaining([ - expect.objectContaining({ code: 'AB7310', severity: 'error' }), + expect.objectContaining({ code: 'AB7321', severity: 'warning' }), ])); + expect(report.diagnostics.some((entry) => entry.code === 'AB7310')).toBe(false); } finally { await fixture.cleanup(); } diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index baf8cd219..66b49824a 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -184,6 +184,20 @@ it('replaces a stale same-version Claude install through uninstall + install and 'plugin install install-fixture@install-fixture-marketplace --scope user', ]); + // A reported copy that cannot be compared never passes as "no drift": fail closed without --replace. + const missingCache = recordingRunner((call) => isInventoryCall(call) + ? claudeInventory(join(fixture.cleanupRoot, 'claude-cache', 'missing')) + : ''); + const uncomparable = await installBundle({ commandRunner: missingCache.runner, from: fixture.from, host: 'claude', scope: 'user' }) + .catch((failure: unknown) => failure); + expect(uncomparable).toBeInstanceOf(DiagnosticError); + expect((uncomparable as DiagnosticError).diagnostics[0]).toMatchObject({ code: 'AB7004', target: 'claude' }); + expect((uncomparable as DiagnosticError).diagnostics[0]?.message).toContain('could not be compared'); + expect(missingCache.calls).toHaveLength(1); + const reinstalled = await installBundle({ commandRunner: missingCache.runner, from: fixture.from, host: 'claude', replace: true, scope: 'user' }); + expect(reinstalled).toMatchObject({ state: 'replaced' }); + expect(reinstalled.previousContentHash).toBeUndefined(); + // A matching row without a readable scope is an unusable inventory, not "not installed". const scopeless = recordingRunner((call) => isInventoryCall(call) ? JSON.stringify([{ id: 'install-fixture@install-fixture-marketplace', installPath: installed, version: '1.2.3' }]) From 68cadbec35c84cbaf26f2501ccccd83e83085108 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 11:23:42 +0000 Subject: [PATCH 11/22] fix(install): require inventory versions; report public-host destinations (#400) - A Claude inventory row without a string version is unusable (Codex already required it), so a stale versionless copy can never pass as a plain install. - Public-host results carry the cache destination (///) so replace messages name the host cache rather than the source bundle. --- packages/agent-bundle/src/install/doctor.ts | 8 ++--- packages/agent-bundle/src/install/install.ts | 28 +++++++++++------ packages/agent-bundle/tests/install.test.ts | 33 +++++++++++--------- 3 files changed, 41 insertions(+), 28 deletions(-) diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index 3e5032ae3..e0d5cc8ef 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -889,7 +889,7 @@ const publicHostInstallComparison = async ( comparisons.push(Object.freeze({ artifactContentHash: artifact.hash, installedPath: entry.installPath, - ...(entry.version === undefined ? {} : { installedVersion: entry.version }), + installedVersion: entry.version, ownership: 'host', status: 'unknown', })); @@ -906,14 +906,14 @@ const publicHostInstallComparison = async ( const status: 'current' | 'stale' | 'version-mismatch' = installed.hash === artifact.hash && entry.version === identity.version ? 'current' - : entry.version !== undefined && entry.version !== identity.version + : entry.version !== identity.version ? 'version-mismatch' : 'stale'; comparisons.push(Object.freeze({ artifactContentHash: artifact.hash, installedContentHash: installed.hash, installedPath: entry.installPath, - ...(entry.version === undefined ? {} : { installedVersion: entry.version }), + installedVersion: entry.version, ownership: 'host', status, })); @@ -921,7 +921,7 @@ const publicHostInstallComparison = async ( artifactContentHash: artifact.hash, installedContentHash: installed.hash, installedName: identity.name, - ...(entry.version === undefined ? {} : { installedVersion: entry.version }), + installedVersion: entry.version, status, }); switch (status) { diff --git a/packages/agent-bundle/src/install/install.ts b/packages/agent-bundle/src/install/install.ts index c42d4de5b..46921ca4e 100644 --- a/packages/agent-bundle/src/install/install.ts +++ b/packages/agent-bundle/src/install/install.ts @@ -1,7 +1,7 @@ import { execFile } from 'node:child_process'; import { lstat, mkdir, readFile, rename, rm } from 'node:fs/promises'; import { homedir } from 'node:os'; -import { join, resolve } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import { Effect, Predicate } from 'effect'; @@ -242,7 +242,7 @@ export interface PublicHostInstalledEntry { readonly installPath: string; /** Claude only: the scope this copy is installed at. */ readonly scope?: string; - readonly version?: string; + readonly version: string; } /** @@ -292,15 +292,18 @@ export const parsePublicHostInventory = ( for (const row of rows) { // Every row for this plugin must be readable before the scope filter runs: a matching row we // cannot read is not "not installed", and replacing on it would skip the uninstall. - if (typeof row['installPath'] !== 'string' || typeof row['scope'] !== 'string') { - return { detail: `claude plugin list --json row for ${id} carries no installPath or scope`, status: 'unavailable' }; + if ( + typeof row['installPath'] !== 'string' || + typeof row['scope'] !== 'string' || + typeof row['version'] !== 'string' + ) { + return { + detail: `claude plugin list --json row for ${id} carries no installPath, scope, or version`, + status: 'unavailable', + }; } if (options.scope !== undefined && row['scope'] !== options.scope) continue; - entries.push({ - installPath: row['installPath'], - scope: row['scope'], - ...(typeof row['version'] === 'string' ? { version: row['version'] } : {}), - }); + entries.push({ installPath: row['installPath'], scope: row['scope'], version: row['version'] }); } return { entries, status: 'available' }; } @@ -395,8 +398,12 @@ const installPublicCli = async ( } as const; let replaced = false; let previousContentHash: string | undefined; + // Both hosts cache at `///` (pinned by the real-host proofs), so a + // reported copy locates where the reinstalled version lands. + let destination: string | undefined; const entry = inventory.status === 'available' ? inventory.entries[0] : undefined; if (entry !== undefined) { + destination = join(dirname(entry.installPath), identity.version); let installed: TreeInventory | undefined; try { installed = await treeInventory(entry.installPath); @@ -416,7 +423,7 @@ const installPublicCli = async ( if (installed !== undefined && sameVersion && installed.hash === artifact.hash) { return { ...base, destination: entry.installPath, state: 'already-installed' }; } - if (entry.version !== undefined && !sameVersion && options.replace !== true) { + if (!sameVersion && options.replace !== true) { const detail = describeContentComparison(identity.plugin, identity.version, { artifactContentHash: artifact.hash, installedContentHash: installed?.hash ?? 'unknown', @@ -449,6 +456,7 @@ const installPublicCli = async ( : ['plugin', 'add', id]); return { ...base, + ...(destination === undefined ? {} : { destination }), ...(previousContentHash === undefined ? {} : { previousContentHash }), state: replaced ? 'replaced' : 'installed', }; diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index 66b49824a..4d171dcff 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -174,6 +174,7 @@ it('replaces a stale same-version Claude install through uninstall + install and const replaced = await installBundle({ commandRunner: runner, from: fixture.from, host: 'claude', scope: 'user' }); expect(replaced).toMatchObject({ contentHash: (await treeInventory(fixture.bundleRoot)).hash, + destination: installed, previousContentHash: (await treeInventory(installed)).hash, state: 'replaced', }); @@ -198,20 +199,23 @@ it('replaces a stale same-version Claude install through uninstall + install and expect(reinstalled).toMatchObject({ state: 'replaced' }); expect(reinstalled.previousContentHash).toBeUndefined(); - // A matching row without a readable scope is an unusable inventory, not "not installed". - const scopeless = recordingRunner((call) => isInventoryCall(call) - ? JSON.stringify([{ id: 'install-fixture@install-fixture-marketplace', installPath: installed, version: '1.2.3' }]) - : ''); - const error = await installBundle({ - commandRunner: scopeless.runner, - from: fixture.from, - host: 'claude', - replace: true, - scope: 'user', - }).catch((failure: unknown) => failure); - expect(error).toBeInstanceOf(DiagnosticError); - expect((error as DiagnosticError).diagnostics[0]?.message).toContain('plugin list --json was unusable'); - expect(scopeless.calls).toHaveLength(1); + // A matching row without a readable scope or version is an unusable inventory, not "not installed". + for (const row of [ + { id: 'install-fixture@install-fixture-marketplace', installPath: installed, version: '1.2.3' }, + { id: 'install-fixture@install-fixture-marketplace', installPath: installed, scope: 'user' }, + ]) { + const malformed = recordingRunner((call) => isInventoryCall(call) ? JSON.stringify([row]) : ''); + const error = await installBundle({ + commandRunner: malformed.runner, + from: fixture.from, + host: 'claude', + replace: true, + scope: 'user', + }).catch((failure: unknown) => failure); + expect(error, JSON.stringify(row)).toBeInstanceOf(DiagnosticError); + expect((error as DiagnosticError).diagnostics[0]?.message).toContain('plugin list --json was unusable'); + expect(malformed.calls).toHaveLength(1); + } } finally { await rm(fixture.cleanupRoot, { force: true, recursive: true }); } @@ -246,6 +250,7 @@ it('honours --replace for Codex through remove + add and fails closed without a calls.length = 0; const forced = await installBundle({ ...options, replace: true }); expect(forced).toMatchObject({ + destination: join(codexHome, 'plugins', 'cache', 'install-fixture-marketplace', 'install-fixture', '1.2.3'), host: 'codex', previousContentHash: (await treeInventory(installed)).hash, state: 'replaced', From b9afba843543dacfc9fc0b3e7cd5b0bbe7d8224d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 11:36:56 +0000 Subject: [PATCH 12/22] fix(install): hash executable bits; reject Windows-normalised receipt paths (#400) - Tree digests now cover path, executable bit, and bytes, so a rebuild that only flips a script's mode is a content change the host receives (Claude and Codex caches preserve modes; verified against both CLIs). - Receipt paths reject Windows-reserved characters, control characters, and trailing dot/space segments that NTFS would normalise onto another entry. - install.mjs mirrors both. --- docs/diagnostics.md | 2 +- packages/agent-bundle/src/install/receipt.ts | 32 ++++++++++++++++---- packages/agent-bundle/src/install/surface.ts | 18 +++++++---- packages/agent-bundle/tests/install.test.ts | 15 +++++++-- 4 files changed, 52 insertions(+), 15 deletions(-) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index f8271dc9a..9364af851 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -681,7 +681,7 @@ install receipt, `.agent-bundle-install.json`, beside the plugin manifest: ```json { - "contentHash": "", + "contentHash": "", "files": [".cursor-plugin/plugin.json", "INSTALL.md", "install.mjs", "..."], "format": "agent-bundle-install-receipt/1", "host": "cursor", diff --git a/packages/agent-bundle/src/install/receipt.ts b/packages/agent-bundle/src/install/receipt.ts index 4331969e2..f195f01ab 100644 --- a/packages/agent-bundle/src/install/receipt.ts +++ b/packages/agent-bundle/src/install/receipt.ts @@ -88,9 +88,21 @@ const sortNames = (names: readonly string[]): readonly string[] => const toPosix = (path: string): string => path.replaceAll('\\', '/'); -const hashEntry = (hash: ReturnType, relativePath: string, bytes: Uint8Array): void => { +/** + * `path\0mode\0bytes\0` per file, where mode is `x` for an executable and `-` + * otherwise: a rebuild that only flips the executable bit on an MCP script is + * a content change the host must receive. + */ +const hashEntry = ( + hash: ReturnType, + relativePath: string, + metadata: Stats, + bytes: Uint8Array, +): void => { hash.update(toPosix(relativePath)); hash.update('\0'); + hash.update((metadata.mode & 0o111) === 0 ? '-' : 'x'); + hash.update('\0'); hash.update(bytes); hash.update('\0'); }; @@ -116,7 +128,7 @@ export const treeInventory = async (root: string): Promise => { return; } files.push(toPosix(relativePath)); - hashEntry(hash, relativePath, await readFile(path)); + hashEntry(hash, relativePath, metadata, await readFile(path)); }; for (const name of sortNames(await readdir(root))) { if (name === installReceiptFile) { @@ -214,7 +226,7 @@ export const hashOwnedFiles = async (root: string, files: readonly string[]): Pr throw error; } if (metadata.isSymbolicLink() || !metadata.isFile()) throw unsupportedEntry(relativePath); - hashEntry(hash, relativePath, await readFile(path)); + hashEntry(hash, relativePath, metadata, await readFile(path)); } return hash.digest('hex'); }; @@ -222,7 +234,9 @@ export const hashOwnedFiles = async (root: string, files: readonly string[]): Pr /** * Receipt paths drive deletions, so they must be exactly what `treeInventory` * emits: POSIX-relative, no backslashes (a Windows `..\outside` must not slip - * past POSIX normalization), no empty, `.`, or `..` segments, no drive letter. + * past POSIX normalization), no empty, `.`, or `..` segments, no drive letter, + * and no segment Windows would normalise onto another entry (reserved + * characters, alternate-stream colons, trailing dots or spaces). */ export const isReceiptPath = (value: unknown): value is string => typeof value === 'string' && @@ -230,8 +244,14 @@ export const isReceiptPath = (value: unknown): value is string => value !== installReceiptFile && !value.includes('\\') && !value.startsWith('/') && - !/^[a-z]:/iu.test(value) && - value.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..'); + value.split('/').every((segment) => + segment !== '' && + segment !== '.' && + segment !== '..' && + !/[<>:"|?*]/u.test(segment) && + [...segment].every((character) => character.charCodeAt(0) >= 0x20) && + !segment.endsWith('.') && + !segment.endsWith(' ')); const isReceiptFileList = (value: unknown): value is readonly string[] => Array.isArray(value) && value.every(isReceiptPath); diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index 6ca6dbb95..cd3f4ea4c 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -207,9 +207,12 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { "const toPosix = (path) => path.replaceAll('\\\\', '/');", 'const short = (hash) => hash.slice(0, 12);', '', - 'const hashEntry = (hash, relative, bytes) => {', + '// path\\0mode\\0bytes\\0 per file; mode is x for an executable and - otherwise.', + 'const hashEntry = (hash, relative, metadata, bytes) => {', ' hash.update(toPosix(relative));', " hash.update('\\0');", + " hash.update((metadata.mode & 0o111) === 0 ? '-' : 'x');", + " hash.update('\\0');", ' hash.update(bytes);', " hash.update('\\0');", '};', @@ -229,7 +232,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' return;', ' }', ' files.push(toPosix(relative));', - ' hashEntry(hash, relative, await readFile(absolute));', + ' hashEntry(hash, relative, metadata, await readFile(absolute));', ' };', ' for (const entry of (await readdir(root)).sort((left, right) => left.localeCompare(right))) {', ' if (entry === receiptFile) {', @@ -289,15 +292,18 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' let metadata;', " try { metadata = await lstat(absolute); } catch (error) { if (error?.code === 'ENOENT') continue; throw error; }", ' if (metadata.isSymbolicLink() || !metadata.isFile()) throw unsupported(relative);', - ' hashEntry(hash, relative, await readFile(absolute));', + ' hashEntry(hash, relative, metadata, await readFile(absolute));', ' }', " return hash.digest('hex');", '};', '', - '// Receipt paths drive deletions: POSIX-relative only, no backslashes, no empty/./.. segments, no drive letter.', + '// Receipt paths drive deletions: POSIX-relative only, no backslashes, no empty/./.. segments, and no', + '// segment Windows would normalise onto another entry (reserved characters, colons, trailing dot/space).', "const safeRelative = (value) => typeof value === 'string' && value.length > 0 && value !== receiptFile &&", - " !value.includes('\\\\') && !value.startsWith('/') && !/^[a-z]:/i.test(value) &&", - " value.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..');", + " !value.includes('\\\\') && !value.startsWith('/') &&", + " value.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..' &&", + " !/[<>:\"|?*]/u.test(segment) && [...segment].every((character) => character.charCodeAt(0) >= 0x20) &&", + " !segment.endsWith('.') && !segment.endsWith(' '));", '', 'const readReceipt = async (root) => {', ' let value;', diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index 4d171dcff..017733462 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -1,4 +1,4 @@ -import { access, cp, link, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { access, chmod, cp, link, mkdir, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; @@ -561,6 +561,14 @@ it('refreshes a receipt whose inventory drifted even when the owned bytes hash e expect(await listFiles(destination)).toEqual([installReceiptFile, '.cursor-plugin/plugin.json', 'payload.txt']); expect(await readFile(join(destination, 'payload.txt'), 'utf8')).toBe('flat again\n'); + // Flipping only the executable bit is a content change: the installed copy must receive it. + await chmod(join(fixture.bundleRoot, 'payload.txt'), 0o755); + const executable = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); + expect(executable).toMatchObject({ state: 'replaced' }); + expect((await stat(join(destination, 'payload.txt'))).mode & 0o111).not.toBe(0); + expect(await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' })) + .toMatchObject({ state: 'already-installed' }); + // An operator hard link to an owned file under an unrelated name is not ours: incoming path → collision. await link(join(destination, 'payload.txt'), join(destination, 'hard-linked.txt')); await writeFile(join(fixture.bundleRoot, 'hard-linked.txt'), 'from the artifact\n'); @@ -687,7 +695,10 @@ it('refuses to hash or write through a symlinked directory inside a receipt-mana it('ignores receipts whose file list could escape the plugin root', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-receipt-')); try { - for (const files of [['..\\outside'], ['../outside'], ['/etc/passwd'], ['a//b'], ['./x'], ['C:/x'], [installReceiptFile]]) { + for (const files of [ + ['..\\outside'], ['../outside'], ['/etc/passwd'], ['a//b'], ['./x'], ['C:/x'], [installReceiptFile], + ['notes.md:stream'], ['trailing.'], ['trailing '], ['bad Date: Thu, 3 Sep 2026 11:49:10 +0000 Subject: [PATCH 13/22] fix(install): runtime state roots are never inventoried, installed, or owned (#400) An artifact run in place can carry state/; tree inventories skip preserved runtime roots on both sides and the staged copy drops them, so the receipt can never own live durable stores. install.mjs mirrors it. --- packages/agent-bundle/src/install/receipt.ts | 7 ++++++- packages/agent-bundle/src/install/surface.ts | 4 +++- packages/agent-bundle/tests/install.test.ts | 15 +++++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/agent-bundle/src/install/receipt.ts b/packages/agent-bundle/src/install/receipt.ts index f195f01ab..e0215df90 100644 --- a/packages/agent-bundle/src/install/receipt.ts +++ b/packages/agent-bundle/src/install/receipt.ts @@ -136,6 +136,9 @@ export const treeInventory = async (root: string): Promise => { if ((await lstat(join(root, name))).isSymbolicLink()) throw unsupportedEntry(name); continue; } + // Runtime-owned roots (`state/`) are never plugin content: not hashed, not installed, not owned, + // whether they sit in an installed copy or in an artifact that was run in place. + if (preservedRuntimeEntries.includes(name)) continue; await visit(name); } return Object.freeze({ files: Object.freeze(files), hash: hash.digest('hex') }); @@ -444,6 +447,9 @@ export const stageArtifact = async (options: { verbatimSymlinks: true, }); await rm(join(root, installReceiptFile), { force: true }); + for (const entry of preservedRuntimeEntries) { + await rm(join(root, entry), { force: true, recursive: true }); + } const inventory = await treeInventory(root); await writeFile( join(root, installReceiptFile), @@ -490,7 +496,6 @@ const previouslyOwnedFiles = async ( const inventory = await treeInventory(destination); const owned: string[] = []; for (const file of inventory.files) { - if (preservedRuntimeEntries.includes(file.split('/')[0] ?? '')) continue; // Exact match, or a case alias of an incoming path on case-insensitive filesystems. if (await isOwnedEntry(destination, incoming, file)) owned.push(file); } diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index cd3f4ea4c..84287a3f6 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -240,6 +240,8 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' if ((await lstat(join(root, entry))).isSymbolicLink()) throw unsupported(entry);', ' continue;', ' }', + ' // Runtime-owned roots (state/) are never plugin content: not hashed, not installed, not owned.', + ' if (preservedEntries.includes(entry)) continue;', ' await visit(entry);', ' }', " return { files, hash: hash.digest('hex') };", @@ -353,6 +355,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' try {', ' await cp(source, root, { errorOnExist: true, force: false, recursive: true, verbatimSymlinks: true });', ' await rm(join(root, receiptFile), { force: true });', + ' for (const entry of preservedEntries) await rm(join(root, entry), { force: true, recursive: true });', ' const staged = await inventory(root);', " await writeFile(join(root, receiptFile), receiptFor(staged), 'utf8');", ' return { inventory: staged, parent, root };', @@ -466,7 +469,6 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' } else {', ' owned = [];', ' for (const file of (await inventory(destination)).files) {', - " if (preservedEntries.includes(file.split('/')[0])) continue;", ' if (await isOwnedEntry(destination, incoming, file)) owned.push(file);', ' }', ' }', diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index 017733462..2de18c25d 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -561,6 +561,21 @@ it('refreshes a receipt whose inventory drifted even when the owned bytes hash e expect(await listFiles(destination)).toEqual([installReceiptFile, '.cursor-plugin/plugin.json', 'payload.txt']); expect(await readFile(join(destination, 'payload.txt'), 'utf8')).toBe('flat again\n'); + // An artifact that was run in place may carry state/: it is never installed, hashed, or owned. + await mkdir(join(destination, 'state'), { recursive: true }); + await writeFile(join(destination, 'state', 'plugin.sqlite'), 'durable\n'); + await mkdir(join(fixture.bundleRoot, 'state'), { recursive: true }); + await writeFile(join(fixture.bundleRoot, 'state', 'plugin.sqlite'), 'artifact-side state\n'); + const withState = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); + expect(withState).toMatchObject({ state: 'already-installed' }); + expect(await readFile(join(destination, 'state', 'plugin.sqlite'), 'utf8')).toBe('durable\n'); + await writeFile(join(fixture.bundleRoot, 'payload.txt'), 'rebuilt with state beside\n'); + const replacedBesideState = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); + expect(replacedBesideState).toMatchObject({ state: 'replaced' }); + expect(await readFile(join(destination, 'state', 'plugin.sqlite'), 'utf8')).toBe('durable\n'); + expect((await readInstallReceipt(destination))?.files.some((file) => file.startsWith('state/'))).toBe(false); + await rm(join(fixture.bundleRoot, 'state'), { recursive: true }); + // Flipping only the executable bit is a content change: the installed copy must receive it. await chmod(join(fixture.bundleRoot, 'payload.txt'), 0o755); const executable = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); From 58007fbc6fe57fa49707fefc254b4b1e33d82c77 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 12:02:33 +0000 Subject: [PATCH 14/22] fix(install,doctor): filter runtime roots during staging; real Claude/Codex inventories (#400) - stageArtifact and install.mjs filter preserved runtime roots and a stray receipt out of the copy itself instead of copying then removing them. - Doctor runs `plugin list --json` once per public host and builds the installed inventory from it (Claude rows per scope, Codex rows via the pinned cache layout); the same listing feeds the bundle comparison. AB7303 is emitted only when the listing is unusable. --- docs/diagnostics.md | 7 +- packages/agent-bundle/src/install/doctor.ts | 164 ++++++++++++++----- packages/agent-bundle/src/install/receipt.ts | 17 +- packages/agent-bundle/src/install/surface.ts | 13 +- packages/agent-bundle/tests/doctor.test.ts | 57 ++++++- 5 files changed, 201 insertions(+), 57 deletions(-) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 9364af851..6473b1a10 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -696,9 +696,10 @@ 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 -`plugin list --json` inventory; the host owns those copies, so replacement runs -`claude plugin uninstall --keep-data` + `install` or `codex plugin remove` + -`add`. +`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 +--keep-data` + `install` or `codex plugin remove` + `add`. | Installed copy | `install` | `install --replace` (alias `--force`) | Doctor | | --- | --- | --- | --- | diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index e0d5cc8ef..ef6e74f3a 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -758,20 +758,110 @@ const cursorInventory = async ( }; }; -const unknownInventory = ( +/** One ` plugin list --json` run: usable JSON text, or the reason it was not. */ +type PublicHostListing = + | { readonly status: 'available'; readonly stdout: string } + | { readonly detail: string; readonly status: 'unavailable' }; + +const readPublicHostListing = async ( host: Exclude, -): { readonly diagnostics: readonly Diagnostic[]; readonly inventory: DoctorInventory } => ({ - diagnostics: freezeDiagnostics([diagnostic( - 'AB7303', - `${host} owns its plugin registry and Agent Bundle has no pinned read-only inventory verb.`, - host === 'claude' - ? 'Use `claude plugin details ` to inspect a known plugin.' - : 'Use Codex-owned commands to inspect installed plugins.', - 'info', - host, - )]), - inventory: freezeInventory('unknown'), -}); + run: DoctorCommandRunner, + cwd: string, +): Promise => { + let result: DoctorCommandResult; + try { + result = await run(Object.freeze({ + args: Object.freeze(['plugin', 'list', '--json']), + cwd, + executable: host, + })); + } catch (error) { + return { detail: error instanceof Error ? error.message : String(error), status: 'unavailable' }; + } + if (result.exitCode !== 0 || result.termination !== undefined) { + return { + detail: result.termination ?? (result.stderr.trim() || `exit code ${result.exitCode ?? 'unknown'}`), + status: 'unavailable', + }; + } + return { status: 'available', stdout: result.stdout }; +}; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** + * The host's installed-plugin inventory from its pinned `plugin list --json` + * verb (Claude rows carry `id`/`version`/`scope`/`installPath`; Codex rows + * carry `pluginId`/`version` and the pinned cache layout supplies the path). + * An unusable listing is reported honestly as unknown (`AB7303`). + */ +const publicHostInventory = ( + host: Exclude, + listing: PublicHostListing, + environment: Readonly, + home: string, +): { readonly diagnostics: readonly Diagnostic[]; readonly inventory: DoctorInventory } => { + const unknown = (detail: string) => ({ + diagnostics: freezeDiagnostics([diagnostic( + 'AB7303', + `${host} inventory is unknown: \`${host} plugin list --json\` was unusable (${detail}).`, + host === 'claude' + ? 'Use `claude plugin list --json` or `claude plugin details ` to inspect installed plugins.' + : 'Use `codex plugin list --json` to inspect installed plugins.', + 'info', + host, + )]), + inventory: freezeInventory('unknown'), + }); + if (listing.status === 'unavailable') return unknown(listing.detail); + let document: unknown; + try { + document = JSON.parse(listing.stdout) as unknown; + } catch { + return unknown('not JSON'); + } + const findings: DoctorFinding[] = []; + if (host === 'claude') { + if (!Array.isArray(document)) return unknown('not an array'); + for (const row of document) { + if ( + !isRecord(row) || + typeof row['id'] !== 'string' || + typeof row['installPath'] !== 'string' || + typeof row['version'] !== 'string' + ) { + return unknown('a row lacks id, installPath, or version'); + } + findings.push({ + entry: typeof row['scope'] === 'string' ? `${row['id']} (${row['scope']})` : row['id'], + name: row['id'].slice(0, row['id'].indexOf('@') === -1 ? undefined : row['id'].indexOf('@')), + path: row['installPath'], + state: 'installed', + version: row['version'], + }); + } + } else { + if (!isRecord(document) || !Array.isArray(document['installed'])) return unknown('no installed array'); + for (const row of document['installed']) { + if (!isRecord(row) || typeof row['pluginId'] !== 'string' || typeof row['version'] !== 'string') { + return unknown('a row lacks pluginId or version'); + } + if (row['installed'] === false) continue; + const separator = row['pluginId'].indexOf('@'); + const name = separator === -1 ? row['pluginId'] : row['pluginId'].slice(0, separator); + const marketplace = separator === -1 ? '' : row['pluginId'].slice(separator + 1); + findings.push({ + entry: row['pluginId'], + name, + path: join(publicHostCacheRoot(host, environment, home), marketplace, name, row['version']), + state: 'installed', + version: row['version'], + }); + } + } + return { diagnostics: Object.freeze([]), inventory: freezeInventory('known', findings) }; +}; const malformedBundle = ( host: DoctorHost, @@ -806,39 +896,23 @@ const installComparison = ( }); /** - * The host's own installed-plugin inventory (`plugin list --json`), read - * through the same parser `agent-bundle install` uses before replacing. - * Doctor looks across scopes, so a Claude row at any scope counts. + * This plugin's copies in the host's inventory, read through the same parser + * `agent-bundle install` uses before replacing, from the listing Doctor + * already ran. Doctor looks across scopes, so a Claude row at any scope counts. */ -const readPublicHostInventory = async ( +const readPublicHostInventory = ( host: Exclude, identity: PluginIdentity, - run: DoctorCommandRunner, + listing: PublicHostListing, environment: Readonly, home: string, -): Promise => { - let result: DoctorCommandResult; - try { - result = await run(Object.freeze({ - args: Object.freeze(['plugin', 'list', '--json']), - cwd: identity.bundleRoot, - executable: host, - })); - } catch (error) { - return { detail: error instanceof Error ? error.message : String(error), status: 'unavailable' }; - } - if (result.exitCode !== 0 || result.termination !== undefined) { - return { - detail: result.termination ?? (result.stderr.trim() || `exit code ${result.exitCode ?? 'unknown'}`), - status: 'unavailable', - }; - } - return parsePublicHostInventory(host, result.stdout, { +): PublicHostInventory => listing.status === 'unavailable' + ? { detail: listing.detail, status: 'unavailable' } + : parsePublicHostInventory(host, listing.stdout, { cacheRoot: publicHostCacheRoot(host, environment, home), marketplace: identity.marketplace ?? '', plugin: identity.name, }); -}; const publicHostReplaceRecipe = (host: Exclude, scopeArguments = ''): string => host === 'claude' ? `Rerun \`agent-bundle install claude --from ${scopeArguments}\`; same-version content drift is replaced through ` + @@ -1176,6 +1250,8 @@ const claudeRegistration = async ( interface PublicHostContext { readonly environment: Readonly; readonly home: string; + /** The host's `plugin list --json` run once per Doctor host pass; shared by inventory and comparison. */ + readonly listing: PublicHostListing; readonly run: DoctorCommandRunner; } @@ -1188,7 +1264,7 @@ const claudeBundle = async ( const registration = await claudeRegistration(identity, probe, context.run); if (probe.status !== 'available' || registration.finding === undefined) return registration; const artifact = await treeInventory(identity.bundleRoot); - const inventory = await readPublicHostInventory('claude', identity, context.run, context.environment, context.home); + const inventory = readPublicHostInventory('claude', identity, context.listing, context.environment, context.home); const compared = await publicHostInstallComparison('claude', identity, artifact, inventory); return { diagnostics: freezeDiagnostics([...registration.diagnostics, ...compared.diagnostics]), @@ -1216,7 +1292,7 @@ const codexBundle = async ( return { diagnostics: Object.freeze([]), finding: Object.freeze({ ...base, state: 'skipped' }) }; } const artifact = await treeInventory(identity.bundleRoot); - const inventory = await readPublicHostInventory('codex', identity, context.run, context.environment, context.home); + const inventory = readPublicHostInventory('codex', identity, context.listing, context.environment, context.home); if (inventory.status === 'unavailable') { return { diagnostics: freezeDiagnostics([diagnostic( @@ -1595,9 +1671,15 @@ const doctorHost = async ( const probed = host === 'cursor' ? await probeCursor(home) : await probeBinary(host, home, run); + const environment = options.environment ?? process.env; + const listing: PublicHostListing = host === 'cursor' || probed.probe.status !== 'available' + ? { detail: `${host} is not available`, status: 'unavailable' } + : await readPublicHostListing(host, run, options.from === undefined ? home : resolve(options.from)); const inventoried = host === 'cursor' ? await cursorInventory(home, probed.probe.status === 'available') - : unknownInventory(host); + : probed.probe.status !== 'available' + ? { diagnostics: Object.freeze([]), inventory: freezeInventory('skipped') } + : publicHostInventory(host, listing, environment, home); const diagnostics = [...probed.diagnostics, ...inventoried.diagnostics]; let bundle: DoctorHostReport['bundle']; if (options.from !== undefined) { @@ -1609,7 +1691,7 @@ const doctorHost = async ( identity.bundleRoot, await validateBundleFiles(identity.bundleRoot, host), ); - const context: PublicHostContext = { environment: options.environment ?? process.env, home, run }; + const context: PublicHostContext = { environment, home, listing, run }; const checked = host === 'cursor' ? await cursorBundle(identity, home) : host === 'claude' diff --git a/packages/agent-bundle/src/install/receipt.ts b/packages/agent-bundle/src/install/receipt.ts index e0215df90..6362d698a 100644 --- a/packages/agent-bundle/src/install/receipt.ts +++ b/packages/agent-bundle/src/install/receipt.ts @@ -14,7 +14,7 @@ import { rmdir, writeFile, } from 'node:fs/promises'; -import { basename, dirname, join } from 'node:path'; +import { basename, dirname, join, relative, resolve, sep } from 'node:path'; import { stableJson } from '../core/digest.ts'; import { isErrno } from '../core/errors.ts'; @@ -440,16 +440,21 @@ export const stageArtifact = async (options: { const parent = await mkdtemp(join(options.stageRoot, `.${basename(options.destination)}.stage-`)); const root = join(parent, 'bundle'); try { - await cp(options.artifactRoot, root, { + // Runtime roots and a stray receipt in the artifact are never copied at all (a run-in-place + // artifact may hold a large live database). + const artifactRoot = resolve(options.artifactRoot); + await cp(artifactRoot, root, { errorOnExist: true, + filter: (source) => { + const relativePath = relative(artifactRoot, source); + if (relativePath === '') return true; + const top = relativePath.split(sep)[0] ?? ''; + return top !== installReceiptFile && !preservedRuntimeEntries.includes(top); + }, force: false, recursive: true, verbatimSymlinks: true, }); - await rm(join(root, installReceiptFile), { force: true }); - for (const entry of preservedRuntimeEntries) { - await rm(join(root, entry), { force: true, recursive: true }); - } const inventory = await treeInventory(root); await writeFile( join(root, installReceiptFile), diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index 84287a3f6..58a50b90a 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -176,7 +176,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { "import { createHash, randomUUID } from 'node:crypto';", "import { cp, lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, rmdir, writeFile } from 'node:fs/promises';", "import { homedir } from 'node:os';", - "import { basename, dirname, join, resolve } from 'node:path';", + "import { basename, dirname, join, relative, resolve, sep } from 'node:path';", "import { fileURLToPath } from 'node:url';", '', `const pluginName = ${name};`, @@ -353,9 +353,14 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' const parent = await mkdtemp(join(installRoot, `.${basename(destination)}.stage-`));', " const root = join(parent, 'bundle');", ' try {', - ' await cp(source, root, { errorOnExist: true, force: false, recursive: true, verbatimSymlinks: true });', - ' await rm(join(root, receiptFile), { force: true });', - ' for (const entry of preservedEntries) await rm(join(root, entry), { force: true, recursive: true });', + ' // Runtime roots and a stray receipt in the artifact are never copied at all.', + ' const filter = (path) => {', + ' const relativePath = relative(source, path);', + " if (relativePath === '') return true;", + ' const top = relativePath.split(sep)[0];', + ' return top !== receiptFile && !preservedEntries.includes(top);', + ' };', + ' await cp(source, root, { errorOnExist: true, filter, force: false, recursive: true, verbatimSymlinks: true });', ' const staged = await inventory(root);', " await writeFile(join(root, receiptFile), receiptFor(staged), 'utf8');", ' return { inventory: staged, parent, root };', diff --git a/packages/agent-bundle/tests/doctor.test.ts b/packages/agent-bundle/tests/doctor.test.ts index 52c17cd25..da8598251 100644 --- a/packages/agent-bundle/tests/doctor.test.ts +++ b/packages/agent-bundle/tests/doctor.test.ts @@ -720,14 +720,14 @@ it('validates --from Claude documents from pinned bytes without a new CLI proof' hosts: ['claude'], }); - // Read-only proofs only: version probe, inline registration proof, installed inventory; never `plugin validate`. + // Read-only proofs only: version probe, installed inventory, inline registration proof; never `plugin validate`. expect(calls).toEqual([ expect.objectContaining({ args: ['--version'], executable: 'claude' }), + expect.objectContaining({ args: ['plugin', 'list', '--json'], cwd: bundle, executable: 'claude' }), expect.objectContaining({ args: ['--plugin-dir', bundle, 'plugin', 'list', '--json'], executable: 'claude', }), - expect.objectContaining({ args: ['plugin', 'list', '--json'], cwd: bundle, executable: 'claude' }), ]); expect(hostReport(report, 'claude').bundle?.state).toBe('corrupt'); expect(report.diagnostics).toEqual(expect.arrayContaining([ @@ -781,7 +781,7 @@ it('reports unreadable Cursor local plugin directory as unknown inventory', asyn } }); -it('reports host-owned Claude and Codex inventories as honestly unknown', async () => { +it('reports Claude and Codex inventories as honestly unknown when plugin list --json is unusable', async () => { const fixture = await temporaryDoctor(); try { const report = await runDoctor({ @@ -792,6 +792,57 @@ it('reports host-owned Claude and Codex inventories as honestly unknown', async }); expect(report.hosts.map((entry) => entry.inventory.status)).toEqual(['unknown', 'unknown']); expect(report.diagnostics.filter((entry) => entry.code === 'AB7303')).toHaveLength(2); + expect(report.diagnostics.find((entry) => entry.code === 'AB7303')?.message).toContain('plugin list --json'); + } finally { + await fixture.cleanup(); + } +}); + +it('inventories Claude and Codex installs from their pinned plugin list --json verbs', async () => { + const fixture = await temporaryDoctor(); + try { + const codexHome = join(fixture.root, 'codex-home'); + const runner: DoctorCommandRunner = async (request) => { + if (request.args[0] === '--version') return commandResult({ stdout: `${request.executable} 1.2.3\n` }); + if (request.executable === 'claude') { + return commandResult({ stdout: JSON.stringify([ + { enabled: true, id: 'alpha@alpha-marketplace', installPath: '/cache/alpha/1.0.0', scope: 'user', version: '1.0.0' }, + { enabled: true, id: 'alpha@alpha-marketplace', installPath: '/cache/alpha-project/1.0.0', scope: 'project', version: '1.0.0' }, + ]) }); + } + return commandResult({ stdout: JSON.stringify({ + available: [], + installed: [ + { enabled: true, installed: true, pluginId: 'beta@beta-marketplace', version: '2.0.0' }, + { enabled: false, installed: false, pluginId: 'gamma@beta-marketplace', version: '3.0.0' }, + ], + }) }); + }; + const report = await runDoctor({ + commandRunner: runner, + endpointDirectory: fixture.endpointDirectory, + environment: { CODEX_HOME: codexHome }, + home: fixture.home, + hosts: ['claude', 'codex'], + }); + expect(report.diagnostics.some((entry) => entry.code === 'AB7303')).toBe(false); + expect(hostReport(report, 'claude').inventory).toEqual({ + findings: [ + { entry: 'alpha@alpha-marketplace (user)', name: 'alpha', path: '/cache/alpha/1.0.0', state: 'installed', version: '1.0.0' }, + { entry: 'alpha@alpha-marketplace (project)', name: 'alpha', path: '/cache/alpha-project/1.0.0', state: 'installed', version: '1.0.0' }, + ], + status: 'known', + }); + expect(hostReport(report, 'codex').inventory).toEqual({ + findings: [{ + entry: 'beta@beta-marketplace', + name: 'beta', + path: join(codexHome, 'plugins', 'cache', 'beta-marketplace', 'beta', '2.0.0'), + state: 'installed', + version: '2.0.0', + }], + status: 'known', + }); } finally { await fixture.cleanup(); } From 650830e5068db0796058791052c64a0ac6334757 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 12:30:22 +0000 Subject: [PATCH 15/22] fix(install): receipts never own runtime roots; receipt must be a regular file; doctor rows need scope (#400) - Receipt file lists reject paths under preserved runtime roots (state/). - The receipt is read only when it is a regular file (not a link, FIFO, or device). - Doctor's Claude inventory rows require a string scope, matching the shared parser. - install.mjs mirrors the first two. --- packages/agent-bundle/src/install/doctor.ts | 5 +++-- packages/agent-bundle/src/install/receipt.ts | 13 ++++++++----- packages/agent-bundle/src/install/surface.ts | 8 ++++---- packages/agent-bundle/tests/install.test.ts | 1 + 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index ef6e74f3a..006ec174d 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -829,12 +829,13 @@ const publicHostInventory = ( !isRecord(row) || typeof row['id'] !== 'string' || typeof row['installPath'] !== 'string' || + typeof row['scope'] !== 'string' || typeof row['version'] !== 'string' ) { - return unknown('a row lacks id, installPath, or version'); + return unknown('a row lacks id, installPath, scope, or version'); } findings.push({ - entry: typeof row['scope'] === 'string' ? `${row['id']} (${row['scope']})` : row['id'], + entry: `${row['id']} (${row['scope']})`, name: row['id'].slice(0, row['id'].indexOf('@') === -1 ? undefined : row['id'].indexOf('@')), path: row['installPath'], state: 'installed', diff --git a/packages/agent-bundle/src/install/receipt.ts b/packages/agent-bundle/src/install/receipt.ts index 6362d698a..337d070fa 100644 --- a/packages/agent-bundle/src/install/receipt.ts +++ b/packages/agent-bundle/src/install/receipt.ts @@ -132,8 +132,8 @@ export const treeInventory = async (root: string): Promise => { }; for (const name of sortNames(await readdir(root))) { if (name === installReceiptFile) { - // The receipt is deletion authority; it is skipped from the hash but may never be a link. - if ((await lstat(join(root, name))).isSymbolicLink()) throw unsupportedEntry(name); + // The receipt is deletion authority; it is skipped from the hash but must be a regular file. + if (!(await lstat(join(root, name))).isFile()) throw unsupportedEntry(name); continue; } // Runtime-owned roots (`state/`) are never plugin content: not hashed, not installed, not owned, @@ -247,6 +247,8 @@ export const isReceiptPath = (value: unknown): value is string => value !== installReceiptFile && !value.includes('\\') && !value.startsWith('/') && + // Runtime roots are never installer-owned, whatever a receipt claims. + !preservedRuntimeEntries.includes(value.split('/')[0] ?? '') && value.split('/').every((segment) => segment !== '' && segment !== '.' && @@ -261,14 +263,15 @@ const isReceiptFileList = (value: unknown): value is readonly string[] => /** * Reads the receipt at a plugin root; malformed or unsafe receipts read as - * absent. A receipt that is a symbolic link is refused outright: it would let - * another file supply the owned-file list that drives deletions. + * absent. A receipt that is not a regular file (a symbolic link, a FIFO, a + * device) is refused outright before it is read: it would let another file + * supply the owned-file list that drives deletions, or block the read. */ export const readInstallReceipt = async (destination: string): Promise => { const path = join(destination, installReceiptFile); let value: unknown; try { - if ((await lstat(path)).isSymbolicLink()) throw unsupportedEntry(installReceiptFile); + if (!(await lstat(path)).isFile()) throw unsupportedEntry(installReceiptFile); value = JSON.parse(await readFile(path, 'utf8')) as unknown; } catch (error) { if (isErrno(error, 'ENOENT') || error instanceof SyntaxError) return undefined; diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index 58a50b90a..684320a3a 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -236,8 +236,8 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' };', ' for (const entry of (await readdir(root)).sort((left, right) => left.localeCompare(right))) {', ' if (entry === receiptFile) {', - ' // The receipt is deletion authority: skipped from the hash, never allowed to be a link.', - ' if ((await lstat(join(root, entry))).isSymbolicLink()) throw unsupported(entry);', + ' // The receipt is deletion authority: skipped from the hash, must be a regular file.', + ' if (!(await lstat(join(root, entry))).isFile()) throw unsupported(entry);', ' continue;', ' }', ' // Runtime-owned roots (state/) are never plugin content: not hashed, not installed, not owned.', @@ -302,7 +302,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { '// Receipt paths drive deletions: POSIX-relative only, no backslashes, no empty/./.. segments, and no', '// segment Windows would normalise onto another entry (reserved characters, colons, trailing dot/space).', "const safeRelative = (value) => typeof value === 'string' && value.length > 0 && value !== receiptFile &&", - " !value.includes('\\\\') && !value.startsWith('/') &&", + " !value.includes('\\\\') && !value.startsWith('/') && !preservedEntries.includes(value.split('/')[0]) &&", " value.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..' &&", " !/[<>:\"|?*]/u.test(segment) && [...segment].every((character) => character.charCodeAt(0) >= 0x20) &&", " !segment.endsWith('.') && !segment.endsWith(' '));", @@ -310,7 +310,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { 'const readReceipt = async (root) => {', ' let value;', ' try {', - ' if ((await lstat(join(root, receiptFile))).isSymbolicLink()) throw unsupported(receiptFile);', + ' if (!(await lstat(join(root, receiptFile))).isFile()) throw unsupported(receiptFile);', " value = JSON.parse(await readFile(join(root, receiptFile), '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;", diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index 2de18c25d..5c1fb4deb 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -713,6 +713,7 @@ it('ignores receipts whose file list could escape the plugin root', async () => for (const files of [ ['..\\outside'], ['../outside'], ['/etc/passwd'], ['a//b'], ['./x'], ['C:/x'], [installReceiptFile], ['notes.md:stream'], ['trailing.'], ['trailing '], ['bad Date: Thu, 3 Sep 2026 12:39:40 +0000 Subject: [PATCH 16/22] fix(install): preserved roots match case-insensitively; tests for FIFO receipts, state-claiming receipts, doctor scope rows (#400) --- docs/diagnostics.md | 6 +- packages/agent-bundle/src/install/receipt.ts | 17 +++- packages/agent-bundle/src/install/surface.ts | 8 +- packages/agent-bundle/tests/doctor.test.ts | 25 +++++ .../tests/install-surface.test.ts | 34 +++++++ packages/agent-bundle/tests/install.test.ts | 92 ++++++++++++++++++- 6 files changed, 172 insertions(+), 10 deletions(-) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 6473b1a10..bbf7e84d9 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -722,8 +722,10 @@ rewritten; when a rebuilt artifact introduces a path that an existing unowned entry already occupies, replacement aborts before any change (`AB7004`, "Refusing to overwrite unowned files") and names the colliding paths. Receipt file lists are validated as strict POSIX-relative paths (no backslashes, no -`..`/`.`/empty segments, no drive letters) before they can drive a deletion; a -receipt that fails validation reads as absent. +`..`/`.`/empty segments, no drive letters, nothing under a runtime root such as +`state/`) before they can drive a deletion; a receipt that fails validation +reads as absent, and a receipt that is not a regular file (a symbolic link, a +FIFO) is refused outright (`AB7004`) before it is read. | Code | Severity | Trigger | Recovery | | --- | --- | --- | --- | diff --git a/packages/agent-bundle/src/install/receipt.ts b/packages/agent-bundle/src/install/receipt.ts index 337d070fa..c9982625d 100644 --- a/packages/agent-bundle/src/install/receipt.ts +++ b/packages/agent-bundle/src/install/receipt.ts @@ -37,6 +37,15 @@ export const installReceiptFormat = 'agent-bundle-install-receipt/1'; /** Root entries owned by generated runtime code; installers never remove or rewrite them. */ export const preservedRuntimeEntries: readonly string[] = Object.freeze(['state']); +/** + * Whether a root entry name is a preserved runtime root. Matched + * case-insensitively: on case-insensitive filesystems `State/` *is* `state/`, + * so no spelling of a runtime root may be inventoried, staged, or claimed by a + * receipt. + */ +export const isPreservedRuntimeRoot = (name: string): boolean => + preservedRuntimeEntries.includes(name.toLowerCase()); + /** * 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 @@ -138,7 +147,7 @@ export const treeInventory = async (root: string): Promise => { } // Runtime-owned roots (`state/`) are never plugin content: not hashed, not installed, not owned, // whether they sit in an installed copy or in an artifact that was run in place. - if (preservedRuntimeEntries.includes(name)) continue; + if (isPreservedRuntimeRoot(name)) continue; await visit(name); } return Object.freeze({ files: Object.freeze(files), hash: hash.digest('hex') }); @@ -247,8 +256,8 @@ export const isReceiptPath = (value: unknown): value is string => value !== installReceiptFile && !value.includes('\\') && !value.startsWith('/') && - // Runtime roots are never installer-owned, whatever a receipt claims. - !preservedRuntimeEntries.includes(value.split('/')[0] ?? '') && + // Runtime roots are never installer-owned, whatever a receipt claims and however it spells them. + !isPreservedRuntimeRoot(value.split('/')[0] ?? '') && value.split('/').every((segment) => segment !== '' && segment !== '.' && @@ -452,7 +461,7 @@ export const stageArtifact = async (options: { const relativePath = relative(artifactRoot, source); if (relativePath === '') return true; const top = relativePath.split(sep)[0] ?? ''; - return top !== installReceiptFile && !preservedRuntimeEntries.includes(top); + return top !== installReceiptFile && !isPreservedRuntimeRoot(top); }, force: false, recursive: true, diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index 684320a3a..8a56e9b23 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -184,6 +184,8 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { `const receiptFile = ${JSON.stringify(installReceiptFile)};`, `const receiptFormat = ${JSON.stringify(installReceiptFormat)};`, `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 cursorRoot = join(homedir(), '.cursor');", @@ -241,7 +243,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' continue;', ' }', ' // Runtime-owned roots (state/) are never plugin content: not hashed, not installed, not owned.', - ' if (preservedEntries.includes(entry)) continue;', + ' if (isPreservedRoot(entry)) continue;', ' await visit(entry);', ' }', " return { files, hash: hash.digest('hex') };", @@ -302,7 +304,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { '// Receipt paths drive deletions: POSIX-relative only, no backslashes, no empty/./.. segments, and no', '// segment Windows would normalise onto another entry (reserved characters, colons, trailing dot/space).', "const safeRelative = (value) => typeof value === 'string' && value.length > 0 && value !== receiptFile &&", - " !value.includes('\\\\') && !value.startsWith('/') && !preservedEntries.includes(value.split('/')[0]) &&", + " !value.includes('\\\\') && !value.startsWith('/') && !isPreservedRoot(value.split('/')[0]) &&", " value.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..' &&", " !/[<>:\"|?*]/u.test(segment) && [...segment].every((character) => character.charCodeAt(0) >= 0x20) &&", " !segment.endsWith('.') && !segment.endsWith(' '));", @@ -358,7 +360,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' const relativePath = relative(source, path);', " if (relativePath === '') return true;", ' const top = relativePath.split(sep)[0];', - ' return top !== receiptFile && !preservedEntries.includes(top);', + ' return top !== receiptFile && !isPreservedRoot(top);', ' };', ' await cp(source, root, { errorOnExist: true, filter, force: false, recursive: true, verbatimSymlinks: true });', ' const staged = await inventory(root);', diff --git a/packages/agent-bundle/tests/doctor.test.ts b/packages/agent-bundle/tests/doctor.test.ts index da8598251..19f29c3d1 100644 --- a/packages/agent-bundle/tests/doctor.test.ts +++ b/packages/agent-bundle/tests/doctor.test.ts @@ -848,6 +848,31 @@ it('inventories Claude and Codex installs from their pinned plugin list --json v } }); +it('reports a Claude listing with a malformed scope as unknown inventory (AB7303) instead of a partial known one', async () => { + const fixture = await temporaryDoctor(); + try { + const runner: DoctorCommandRunner = async (request) => { + if (request.args[0] === '--version') return commandResult({ stdout: `${request.executable} 1.2.3\n` }); + return commandResult({ stdout: JSON.stringify([ + { enabled: true, id: 'alpha@alpha-marketplace', installPath: '/cache/alpha/1.0.0', scope: 'user', version: '1.0.0' }, + { enabled: true, id: 'alpha@alpha-marketplace', installPath: '/cache/alpha-project/1.0.0', version: '1.0.0' }, + ]) }); + }; + const report = await runDoctor({ + commandRunner: runner, + endpointDirectory: fixture.endpointDirectory, + home: fixture.home, + hosts: ['claude'], + }); + expect(hostReport(report, 'claude').inventory).toEqual({ findings: [], status: 'unknown' }); + const unusable = report.diagnostics.filter((entry) => entry.code === 'AB7303'); + expect(unusable).toHaveLength(1); + expect(unusable[0]?.message).toContain('a row lacks id, installPath, scope, or version'); + } finally { + await fixture.cleanup(); + } +}); + it('reports a malformed host bundle as a Doctor error', async () => { const fixture = await temporaryDoctor(); try { diff --git a/packages/agent-bundle/tests/install-surface.test.ts b/packages/agent-bundle/tests/install-surface.test.ts index 1f94d2fce..5f29b0ca0 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -155,6 +155,8 @@ const run = async ( const result = await execFile(process.execPath, [installer, ...args], { cwd: dirname(installer), env: { ...process.env, HOME: home }, + // A regression that reads a FIFO receipt would otherwise hang the whole suite. + timeout: 30_000, }); return { code: 0, stderr: result.stderr, stdout: result.stdout }; } catch (error) { @@ -293,7 +295,39 @@ it('emitted install.mjs mirrors the core replace policy: no-op, owned-only repla expect(partial.code).toBe(1); expect(partial.stderr).toContain('Refusing content collision'); expect(partial.stderr).toContain('predates install receipts'); + + // A receipt claiming runtime state reads as absent too: the durable store is never deletion-eligible. + await mkdir(join(destination, 'state'), { recursive: true }); + await writeFile(join(destination, 'state', 'plugin.sqlite'), 'durable\n'); + await writeFile(join(destination, installReceiptFile), JSON.stringify({ + ...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'); + expect(await readFile(join(destination, 'state', 'plugin.sqlite'), 'utf8')).toBe('durable\n'); + expect((await readInstallReceipt(destination))?.files.some((file) => file.startsWith('state/'))).toBe(false); + await rm(join(destination, 'state'), { recursive: true }); await writeFile(join(bundle, 'payload.txt'), 'rebuilt\n'); + await run(installer, [], home); + + // A receipt that is not a regular file (a FIFO would block the read forever) is refused before reading. + if (process.platform !== 'win32') { + await rm(join(destination, installReceiptFile)); + await execFile('mkfifo', [join(destination, installReceiptFile)]); + const fifo = await run(installer, [], home); + expect(fifo.code).toBe(1); + expect(fifo.stderr).toContain(`Refusing unsupported filesystem entry "${installReceiptFile}"`); + const forcedFifo = await run(installer, ['--replace'], home); + expect(forcedFifo.code).toBe(1); + expect(forcedFifo.stderr).toContain(`Refusing unsupported filesystem entry "${installReceiptFile}"`); + await rm(join(destination, installReceiptFile)); + } // An unowned symlinked directory beneath which the artifact starts writing: refused before any change. await writeFile(join(destination, installReceiptFile), JSON.stringify(receipt)); diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index 5c1fb4deb..85a4a34c3 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -1,6 +1,8 @@ +import { execFile as executeFile } from 'node:child_process'; import { access, chmod, cp, link, mkdir, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; +import { promisify } from 'node:util'; import { expect, it } from '@rstest/core'; @@ -52,6 +54,13 @@ const writeJson = async (path: string, value: unknown): Promise => { await writeFile(path, `${JSON.stringify(value)}\n`); }; +const execFile = promisify(executeFile); + +/** Creates a named pipe; Windows has no FIFOs, so callers skip there. */ +const makeFifo = async (path: string): Promise => { + await execFile('mkfifo', [path]); +}; + const createHostBundle = async ( host: 'claude' | 'codex' | 'cursor', options: { readonly artifactRoot?: boolean } = {}, @@ -713,7 +722,7 @@ it('ignores receipts whose file list could escape the plugin root', async () => for (const files of [ ['..\\outside'], ['../outside'], ['/etc/passwd'], ['a//b'], ['./x'], ['C:/x'], [installReceiptFile], ['notes.md:stream'], ['trailing.'], ['trailing '], ['bad } }); +it('never lets a receipt claim runtime state: a receipt owning state/ reads as legacy and the store survives', 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 installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); + 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. + 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. + await writeFile(join(fixture.bundleRoot, 'payload.txt'), 'rebuilt\n'); + 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'); + 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([ + rm(fixture.cleanupRoot, { force: true, recursive: true }), + rm(home, { force: true, recursive: true }), + ]); + } +}); + +it('refuses a receipt that is not a regular file before reading it', async () => { + if (process.platform === 'win32') return; + 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 { + await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); + const receiptPath = join(destination, installReceiptFile); + await rm(receiptPath); + // A FIFO with no writer would block `readFile` forever; the lstat gate refuses it instead. + await makeFifo(receiptPath); + + const expectRefused = (failure: unknown): void => { + expect(failure).toBeInstanceOf(DiagnosticError); + expect((failure as DiagnosticError).diagnostics[0]).toMatchObject({ code: 'AB7004', target: 'cursor' }); + expect((failure as DiagnosticError).diagnostics[0]?.message).toContain( + `Refusing unsupported filesystem entry "${installReceiptFile}"`, + ); + }; + expectRefused(await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }) + .catch((failure: unknown) => failure)); + expectRefused(await installBundle({ from: fixture.from, home, host: 'cursor', replace: true, scope: 'user' }) + .catch((failure: unknown) => failure)); + await expect(readInstallReceipt(destination)).rejects.toThrow( + `Refusing unsupported filesystem entry "${installReceiptFile}"`, + ); + await expect(treeInventory(destination)).rejects.toThrow( + `Refusing unsupported filesystem entry "${installReceiptFile}"`, + ); + expect(await readFile(join(destination, 'payload.txt'), 'utf8')).toBe('payload\n'); + } finally { + await Promise.all([ + rm(fixture.cleanupRoot, { force: true, recursive: true }), + rm(home, { force: true, recursive: true }), + ]); + } +}); + it('refuses foreign Cursor directories even with --replace and gates version collisions behind it', async () => { const fixture = await createHostBundle('cursor'); const home = await mkdtemp(join(tmpdir(), 'agent-bundle-home-')); From 9bf12701a7b004de940927cb19501bc3adcee11b Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 12:58:59 +0000 Subject: [PATCH 17/22] fix(install): receipts own the directories the installer created; only those are pruned (#400) --- docs/diagnostics.md | 17 ++- docs/framework-mode.md | 2 +- packages/agent-bundle/README.md | 3 +- packages/agent-bundle/src/install/install.ts | 4 +- packages/agent-bundle/src/install/receipt.ts | 131 +++++++++++++++--- packages/agent-bundle/src/install/surface.ts | 65 ++++++--- .../tests/install-surface.test.ts | 19 +++ packages/agent-bundle/tests/install.test.ts | 62 +++++++-- 8 files changed, 240 insertions(+), 63 deletions(-) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index bbf7e84d9..6ccf4b829 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -682,6 +682,7 @@ install receipt, `.agent-bundle-install.json`, beside the plugin manifest: ```json { "contentHash": "", + "directories": [".cursor-plugin", "..."], "files": [".cursor-plugin/plugin.json", "INSTALL.md", "install.mjs", "..."], "format": "agent-bundle-install-receipt/1", "host": "cursor", @@ -715,13 +716,15 @@ Every `AB7005`, `AB7308`, `AB7309`, and `AB7321` message carries the comparison `installed @ content vs artifact @ content (same version, different content | different version | same content)`. Cursor replacement is in place and touches owned files only: stale owned files -are removed and their empty directories pruned, staged files are renamed over -their predecessors, and the receipt lands last. Entries the installer does not -own — notably workspace-durable `state/` stores — 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 unowned files") and names the colliding paths. Receipt -file lists are validated as strict POSIX-relative paths (no backslashes, no +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 +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 +unowned files") and names the colliding paths. Receipt file and directory lists +are validated as strict POSIX-relative paths (no backslashes, no `..`/`.`/empty segments, no drive letters, nothing under a runtime root such as `state/`) before they can drive a deletion; a receipt that fails validation reads as absent, and a receipt that is not a regular file (a symbolic link, a diff --git a/docs/framework-mode.md b/docs/framework-mode.md index bbdc2e084..c66039244 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -364,7 +364,7 @@ agent-bundle install cursor --from artifact/cursor Cursor-compatible `cursor`, `portable`, and multi-host `plugin` targets also include 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), replaces a same-version stale copy of its +version, content hash, owned files and directories), replaces a same-version stale copy of its own plugin in place (owned files only; `state/` survives), and accepts `--replace` (alias `--force`) to replace a different installed version or adopt a pre-receipt copy. Foreign directories are refused with a content-hash diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index fc50cd9f5..e47227cdc 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -183,7 +183,8 @@ every host treats it differently. `agent-bundle install` and the emitted - **Receipt.** Every Cursor copy an agent-bundle installer places carries `.agent-bundle-install.json` beside the plugin manifest: the plugin name, version, host, the sha256 content hash of the artifact tree, when it was - installed, and the exact list of files the installer owns. The receipt never + installed, the exact list of files the installer owns, and the directories + it created (the only ones it will ever prune). The receipt never participates in the content hash, so an installed copy hashes like the artifact it came from. - **No-op.** Re-running install on an identical artifact reports diff --git a/packages/agent-bundle/src/install/install.ts b/packages/agent-bundle/src/install/install.ts index 46921ca4e..023c64b51 100644 --- a/packages/agent-bundle/src/install/install.ts +++ b/packages/agent-bundle/src/install/install.ts @@ -563,7 +563,9 @@ const installCursor = async ( }); 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. await writeInstallReceipt(destination, createInstallReceipt({ + directories: [], host: 'cursor', inventory: artifact, plugin: identity.plugin, @@ -581,7 +583,7 @@ const installCursor = async ( } const staged = await stageArtifact({ artifactRoot: identity.bundleRoot, destination, receipt, stageRoot: installRoot }); try { - await replaceInstalledTree({ comparison, destination, staged }); + await replaceInstalledTree({ comparison, destination, receipt, staged }); } finally { await rm(staged.parent, { force: true, recursive: true }); } diff --git a/packages/agent-bundle/src/install/receipt.ts b/packages/agent-bundle/src/install/receipt.ts index c9982625d..ef9cacf13 100644 --- a/packages/agent-bundle/src/install/receipt.ts +++ b/packages/agent-bundle/src/install/receipt.ts @@ -55,6 +55,12 @@ export const installSurfaceMarkerFiles: readonly string[] = Object.freeze(['INST export interface InstallReceipt { readonly contentHash: string; + /** + * Directories the installer created (POSIX-relative, sorted). Only these + * are ever pruned when they empty out; a directory that existed before the + * installer wrote beneath it belongs to whoever made it. + */ + readonly directories: readonly string[]; readonly files: readonly string[]; readonly format: typeof installReceiptFormat; readonly host: string; @@ -97,6 +103,19 @@ const sortNames = (names: readonly string[]): readonly string[] => const toPosix = (path: string): string => path.replaceAll('\\', '/'); +/** Every ancestor directory of the given POSIX-relative files, deduplicated and sorted. */ +export const directoriesOf = (files: readonly string[]): readonly string[] => { + const directories = new Set(); + for (const file of files) { + let directory = dirname(file); + while (directory !== '.' && directory !== '') { + directories.add(directory); + directory = dirname(directory); + } + } + return Object.freeze(sortNames([...directories])); +}; + /** * `path\0mode\0bytes\0` per file, where mode is `x` for an executable and `-` * otherwise: a rebuild that only flips the executable bit on an MCP script is @@ -295,12 +314,14 @@ export const readInstallReceipt = async (destination: string): Promise Object.freeze({ contentHash: options.inventory.hash, + directories: options.directories ?? directoriesOf(options.inventory.files), files: options.inventory.files, format: installReceiptFormat, host: options.host, @@ -480,22 +509,53 @@ export const stageArtifact = async (options: { } }; -const pruneEmptyDirectories = async (destination: string, removedFiles: readonly string[]): Promise => { - const candidates = new Set(); - for (const file of removedFiles) { - let directory = dirname(file); - while (directory !== '.' && directory !== '') { - candidates.add(directory); - directory = dirname(directory); - } - } +/** + * Removes the now-empty ancestors of removed files, deepest first, but only + * those the installer created: a pre-existing directory that merely became an + * ancestor of an owned file is not ours to delete. Returns what was pruned. + */ +const pruneEmptyDirectories = async ( + destination: string, + removedFiles: readonly string[], + ownedDirectories: ReadonlySet, +): Promise> => { + const pruned = new Set(); + const candidates = directoriesOf(removedFiles).filter((directory) => ownedDirectories.has(directory)); for (const directory of [...candidates].sort((left, right) => right.length - left.length)) { try { await rmdir(join(destination, directory)); + pruned.add(directory); } catch (error) { if (!isErrno(error, 'ENOTEMPTY') && !isErrno(error, 'ENOENT') && !isErrno(error, 'EEXIST')) throw error; } } + return pruned; +}; + +/** + * Creates the missing ancestors of a target path one level at a time and + * reports the ones this call created, so the receipt can own exactly those. + */ +const ensureAncestors = async ( + destination: string, + file: string, + created: Set, +): Promise => { + const ancestors: string[] = []; + let directory = dirname(file); + while (directory !== '.' && directory !== '') { + ancestors.unshift(directory); + directory = dirname(directory); + } + for (const ancestor of ancestors) { + if (created.has(ancestor)) continue; + try { + await mkdir(join(destination, ancestor)); + created.add(ancestor); + } catch (error) { + if (!isErrno(error, 'EEXIST')) throw error; + } + } }; /** @@ -520,17 +580,21 @@ const previouslyOwnedFiles = async ( }; /** - * True when a directory is entirely previous-installer territory: at least - * one file, every file owned, and no empty directories anywhere beneath (an - * empty directory is no evidence of ownership and would survive pruning). + * True when a directory is entirely previous-installer territory: the + * directory and every directory beneath it were created by the installer, it + * holds at least one file, every file is owned, and no directory anywhere + * beneath is empty (an empty directory is no evidence of ownership and would + * survive pruning). Only such a directory may make way for an incoming file. */ const isWhollyOwnedDirectory = async ( destination: string, relativePath: string, owned: ReadonlySet, + ownedDirectories: ReadonlySet, ): Promise => { let files = 0; const visit = async (directory: string): Promise => { + if (!await isOwnedEntry(destination, ownedDirectories, directory)) return false; const entries = sortNames(await readdir(join(destination, directory))); if (entries.length === 0) return false; for (const name of entries) { @@ -549,16 +613,27 @@ 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 directories are pruned), every staged file then - * moves over its predecessor with an atomic rename, and the receipt lands - * last as the commit marker. Unowned entries are never touched; an incoming - * file that would land on an existing unowned entry aborts before any change. + * first (their now-empty installer-created directories are pruned), every + * staged file then moves over its predecessor with an atomic rename, and the + * receipt — recording the files and the directories the installer owns after + * this replacement — lands last as the commit marker. Unowned entries, + * including directories that existed before the installer wrote beneath + * them, are never touched; an incoming file that would land on an existing + * unowned entry aborts before any change. */ export const replaceInstalledTree = async (options: { readonly comparison: InstalledTreeComparison; readonly destination: string; + readonly receipt: { readonly host: string; readonly installedAt?: string; readonly plugin: string; readonly version: string }; readonly staged: StagedArtifact; }): Promise => { if (options.comparison.ownership === 'foreign') { @@ -567,6 +642,7 @@ export const replaceInstalledTree = async (options: { const incoming = new Set(options.staged.inventory.files); const previouslyOwned = await previouslyOwnedFiles(options.destination, options.comparison, incoming); const owned = new Set(previouslyOwned); + const ownedDirectories = new Set(previouslyOwnedDirectories(options.comparison)); 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 @@ -585,7 +661,7 @@ export const replaceInstalledTree = async (options: { throw error; } const tolerated = metadata.isDirectory() && !metadata.isSymbolicLink() - ? await isWhollyOwnedDirectory(options.destination, file, owned) + ? await isWhollyOwnedDirectory(options.destination, file, owned, ownedDirectories) : metadata.isFile() && await isOwnedEntry(options.destination, owned, file); if (!tolerated) collisions.push(file); } @@ -599,11 +675,22 @@ export const replaceInstalledTree = async (options: { for (const file of stale) { await rm(join(options.destination, file), { force: true }); } - await pruneEmptyDirectories(options.destination, stale); + const pruned = await pruneEmptyDirectories(options.destination, stale, ownedDirectories); + const created = new Set(); for (const file of options.staged.inventory.files) { - const target = join(options.destination, file); - await mkdir(dirname(target), { recursive: true }); - await rename(join(options.staged.root, file), target); + await ensureAncestors(options.destination, file, created); + await rename(join(options.staged.root, file), join(options.destination, file)); } + // The receipt owns what the installer owns now: the surviving directories it created before plus + // the ones this replacement created. It is finalised in the private staging copy, then committed. + const directories = sortNames([...new Set([ + ...[...ownedDirectories].filter((directory) => !pruned.has(directory)), + ...created, + ])]); + await writeFile( + join(options.staged.root, installReceiptFile), + receiptDocument(createInstallReceipt({ ...options.receipt, directories, inventory: options.staged.inventory })), + 'utf8', + ); await rename(join(options.staged.root, installReceiptFile), join(options.destination, installReceiptFile)); }; diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index 8a56e9b23..387143283 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -208,6 +208,16 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { "const unsupported = (relative) => new Error(`Refusing unsupported filesystem entry ${JSON.stringify(relative || '.')}.`);", "const toPosix = (path) => path.replaceAll('\\\\', '/');", 'const short = (hash) => hash.slice(0, 12);', + 'const sortNames = (names) => [...names].sort((left, right) => left.localeCompare(right));', + '// Every ancestor directory of the given POSIX-relative files, deduplicated and sorted.', + 'const directoriesOf = (files) => {', + ' const directories = new Set();', + ' for (const file of files) {', + ' let directory = dirname(file);', + " while (directory !== '.' && directory !== '') { directories.add(directory); directory = dirname(directory); }", + ' }', + ' return sortNames(directories);', + '};', '', '// path\\0mode\\0bytes\\0 per file; mode is x for an executable and - otherwise.', 'const hashEntry = (hash, relative, metadata, bytes) => {', @@ -319,7 +329,8 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' // Same shape check as the core reader: a receipt missing any field reads as absent.', " 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)) return undefined;', + ' !Array.isArray(value.files) || !value.files.every(safeRelative) ||', + ' !Array.isArray(value.directories) || !value.directories.every(safeRelative)) return undefined;', ' return value;', '};', '', @@ -340,8 +351,10 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' return true;', '};', '', - 'const receiptFor = (tree) => JSON.stringify({', + '// directories: the ones the installer created (all of them on a fresh install); only those are ever pruned.', + 'const receiptFor = (tree, directories = directoriesOf(tree.files)) => JSON.stringify({', ' contentHash: tree.hash,', + ' directories,', ' files: tree.files,', ' format: receiptFormat,', " host: 'cursor',", @@ -372,16 +385,27 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' }', '};', '', - 'const pruneEmptyDirectories = async (removed) => {', - ' const candidates = new Set();', - ' for (const file of removed) {', - ' let directory = dirname(file);', - " while (directory !== '.' && directory !== '') { candidates.add(directory); directory = dirname(directory); }", - ' }', - ' for (const directory of [...candidates].sort((left, right) => right.length - left.length)) {', - ' try { await rmdir(join(destination, directory)); }', + '// Prunes the now-empty ancestors of removed files, deepest first, but only installer-created ones:', + '// a pre-existing directory that merely became an ancestor of an owned file is not ours to delete.', + 'const pruneEmptyDirectories = async (removed, ownedDirectories) => {', + ' const pruned = new Set();', + ' const candidates = directoriesOf(removed).filter((directory) => ownedDirectories.has(directory));', + ' for (const directory of candidates.sort((left, right) => right.length - left.length)) {', + ' try { await rmdir(join(destination, directory)); pruned.add(directory); }', " catch (error) { if (!['ENOTEMPTY', 'ENOENT', 'EEXIST'].includes(error?.code)) throw error; }", ' }', + ' return pruned;', + '};', + '// Creates missing ancestors one level at a time and records the ones this run created.', + 'const ensureAncestors = async (file, created) => {', + ' const ancestors = [];', + ' let directory = dirname(file);', + " while (directory !== '.' && directory !== '') { ancestors.unshift(directory); directory = dirname(directory); }", + ' for (const ancestor of ancestors) {', + ' if (created.has(ancestor)) continue;', + ' try { await mkdir(join(destination, ancestor)); created.add(ancestor); }', + " catch (error) { if (error?.code !== 'EEXIST') throw error; }", + ' }', '};', '', 'if (!(await exists(cursorRoot)) || !(await lstat(cursorRoot)).isDirectory()) {', @@ -443,7 +467,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' const temporary = join(destination, `${receiptFile}.${randomUUID()}.tmp`);', " const handle = await open(temporary, 'wx');", ' try {', - " await handle.writeFile(receiptFor(artifact), 'utf8');", + " await handle.writeFile(receiptFor(artifact, []), 'utf8'); // adoption created no directories", ' await handle.close();', ' await rename(temporary, join(destination, receiptFile));', ' } finally {', @@ -480,13 +504,16 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' }', ' }', ' const ownedSet = new Set(owned);', + " const ownedDirectories = new Set(ownership === 'receipt' ? 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: at least one', - ' // file, every file owned, and no empty directory anywhere beneath (no evidence of ownership).', + ' // An existing directory at an incoming file path is fine only when it is wholly owned: it and every', + ' // directory beneath were created by the installer, at least one file, every file owned, and no empty', + ' // directory anywhere beneath (no evidence of ownership).', ' const isWhollyOwnedDirectory = async (relativePath) => {', ' let files = 0;', ' const visit = async (directory) => {', + ' if (!(await isOwnedEntry(destination, ownedDirectories, directory))) return false;', ' const entries = (await readdir(join(destination, directory))).sort((left, right) => left.localeCompare(right));', ' if (entries.length === 0) return false;', ' for (const name of entries) {', @@ -517,12 +544,16 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' }', ' const stale = owned.filter((file) => !incoming.has(file));', ' for (const file of stale) await rm(join(destination, file), { force: true });', - ' await pruneEmptyDirectories(stale);', + ' const pruned = await pruneEmptyDirectories(stale, ownedDirectories);', + ' const created = new Set();', ' for (const file of staged.inventory.files) {', - ' const target = join(destination, file);', - ' await mkdir(dirname(target), { recursive: true });', - ' await rename(join(staged.root, file), target);', + ' await ensureAncestors(file, created);', + ' await rename(join(staged.root, file), join(destination, file));', ' }', + ' // The receipt owns what the installer owns now: surviving directories it created before plus the', + ' // ones this replacement created. Finalised in the private staging copy, then committed by rename.', + ' const directories = sortNames(new Set([...[...ownedDirectories].filter((directory) => !pruned.has(directory)), ...created]));', + " await writeFile(join(staged.root, receiptFile), receiptFor(staged.inventory, directories), 'utf8');", ' await rename(join(staged.root, receiptFile), join(destination, receiptFile));', ' console.log(`Replaced ${pluginName}@${pluginVersion} at ${destination} (content ${short(installedHash)} -> ${short(artifact.hash)})`);', '} finally {', diff --git a/packages/agent-bundle/tests/install-surface.test.ts b/packages/agent-bundle/tests/install-surface.test.ts index 5f29b0ca0..1b4a2f72e 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -203,6 +203,7 @@ it('emitted install.mjs mirrors the core replace policy: no-op, owned-only repla // The emitted receipt is byte-compatible with the core reader. expect(await readInstallReceipt(destination)).toMatchObject({ contentHash: firstArtifact.hash, + directories: ['.cursor-plugin'], files: firstArtifact.files, host: 'cursor', plugin: 'install-fixture', @@ -277,6 +278,24 @@ it('emitted install.mjs mirrors the core replace policy: no-op, owned-only repla expect(flattened.stdout).toContain('Replaced install-fixture@1.2.3'); 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.) + 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']); + await rm(join(bundle, 'operator-dir'), { recursive: true }); + await rm(join(bundle, 'skills'), { recursive: true }); + 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([]); + await rm(join(destination, 'operator-dir'), { recursive: true }); + // A receipt whose inventory drifted is refreshed even when the owned bytes hash equal. await writeFile(join(bundle, 'transient.txt'), 'transient\n'); await run(installer, [], home); diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index 85a4a34c3..dff2574b3 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -372,6 +372,8 @@ it('copies a Cursor bundle into a fake home and is idempotent', async () => { expect(await readFile(join(destination, 'payload.txt'), 'utf8')).toBe('payload\n'); expect(await readInstallReceipt(destination)).toMatchObject({ contentHash: artifact.hash, + // A fresh install created every directory, so it owns them all. + directories: ['.cursor-plugin'], files: ['.cursor-plugin/plugin.json', 'payload.txt'], format: 'agent-bundle-install-receipt/1', host: 'cursor', @@ -427,6 +429,7 @@ it('replaces a stale same-version receipt-managed Cursor install in place, touch expect(await readFile(join(destination, 'state', 'plugin.sqlite'), 'utf8')).toBe('durable\n'); expect(await readInstallReceipt(destination)).toMatchObject({ contentHash: artifact.hash, + directories: ['.cursor-plugin', 'skills', 'skills/new'], files: ['.cursor-plugin/plugin.json', 'payload.txt', 'skills/new/SKILL.md'], }); @@ -434,6 +437,26 @@ it('replaces a stale same-version receipt-managed Cursor install in place, touch expect(again).toMatchObject({ contentHash: artifact.hash, state: 'already-installed' }); expect(again.previousContentHash).toBeUndefined(); + // Directories the installer created are pruned once a rebuild empties them; a directory that + // existed before the installer wrote beneath it is not the installer's to delete. + await mkdir(join(destination, 'operator-dir')); + await mkdir(join(fixture.bundleRoot, 'operator-dir')); + await writeFile(join(fixture.bundleRoot, 'operator-dir', 'shipped.md'), '# shipped\n'); + await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); + expect((await readInstallReceipt(destination))?.directories).toEqual(['.cursor-plugin', 'skills', 'skills/new']); + await rm(join(fixture.bundleRoot, 'operator-dir'), { recursive: true }); + await rm(join(fixture.bundleRoot, 'skills'), { recursive: true }); + expect(await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' })).toMatchObject({ state: 'replaced' }); + expect((await stat(join(destination, 'operator-dir'))).isDirectory()).toBe(true); + expect(await readdir(join(destination, 'operator-dir'))).toEqual([]); + await expect(access(join(destination, 'skills'))).rejects.toMatchObject({ code: 'ENOENT' }); + expect((await readInstallReceipt(destination))?.directories).toEqual(['.cursor-plugin']); + await rm(join(destination, 'operator-dir'), { recursive: true }); + await mkdir(join(fixture.bundleRoot, 'skills', 'new'), { recursive: true }); + await writeFile(join(fixture.bundleRoot, 'skills', 'new', 'SKILL.md'), '# new\n'); + expect(await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' })).toMatchObject({ state: 'replaced' }); + expect((await readInstallReceipt(destination))?.directories).toEqual(['.cursor-plugin', 'skills', 'skills/new']); + // An incoming file that would land on an existing unowned file aborts before any change. await writeFile(join(fixture.bundleRoot, 'operator-note.txt'), 'from the artifact\n'); const collision = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }) @@ -469,7 +492,8 @@ it('requires --replace for a legacy pre-receipt Cursor copy and then adopts it', 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' }); - expect(await readInstallReceipt(destination)).toMatchObject({ plugin: 'install-fixture', version: '1.2.3' }); + // 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'); @@ -496,7 +520,7 @@ it('requires --replace for a legacy pre-receipt Cursor copy and then adopts it', 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, plugin: 'install-fixture' }); + 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'); @@ -681,6 +705,7 @@ it('refuses to hash or write through a symlinked directory inside a receipt-mana await rm(receiptPath); await writeFile(join(elsewhere, 'receipt.json'), JSON.stringify({ contentHash: 'abc', + directories: [], files: ['payload.txt'], format: 'agent-bundle-install-receipt/1', host: 'cursor', @@ -724,19 +749,25 @@ it('ignores receipts whose file list could escape the plugin root', async () => ['notes.md:stream'], ['trailing.'], ['trailing '], ['bad plugin: 'install-fixture', version: '1.2.3', }; - for (const missing of ['host', 'installedAt', 'contentHash', 'version', 'plugin', 'format'] as const) { + for (const missing of ['host', 'installedAt', 'contentHash', 'version', 'plugin', 'format', 'directories', 'files'] as const) { const { [missing]: _omitted, ...partial } = complete; await writeJson(join(root, installReceiptFile), partial); expect(await readInstallReceipt(root), missing).toBeUndefined(); } await writeJson(join(root, installReceiptFile), complete); - expect(await readInstallReceipt(root)).toMatchObject({ files: ['skills/probe/SKILL.md', 'plugin.json'] }); + expect(await readInstallReceipt(root)).toMatchObject({ + directories: ['skills', 'skills/probe'], + files: ['skills/probe/SKILL.md', 'plugin.json'], + }); } finally { await rm(root, { force: true, recursive: true }); } From 92736f707d0ffd0e8caf2b171c4a1e908f0b9fb1 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 13:10:53 +0000 Subject: [PATCH 18/22] fix(install): empty artifact directories are not content: never staged, hashed, or owned (#400) --- docs/diagnostics.md | 5 ++++- packages/agent-bundle/src/install/receipt.ts | 20 +++++++++++-------- packages/agent-bundle/src/install/surface.ts | 16 +++++++-------- .../tests/install-surface.test.ts | 4 ++++ packages/agent-bundle/tests/install.test.ts | 4 ++++ 5 files changed, 32 insertions(+), 17 deletions(-) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 6ccf4b829..536cd32a2 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -692,7 +692,10 @@ install receipt, `.agent-bundle-install.json`, beside the plugin manifest: } ``` -The receipt never participates in the content hash. Ownership of an existing +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** diff --git a/packages/agent-bundle/src/install/receipt.ts b/packages/agent-bundle/src/install/receipt.ts index ef9cacf13..b673ad42b 100644 --- a/packages/agent-bundle/src/install/receipt.ts +++ b/packages/agent-bundle/src/install/receipt.ts @@ -14,7 +14,7 @@ import { rmdir, writeFile, } from 'node:fs/promises'; -import { basename, dirname, join, relative, resolve, sep } from 'node:path'; +import { basename, dirname, join, relative, resolve } from 'node:path'; import { stableJson } from '../core/digest.ts'; import { isErrno } from '../core/errors.ts'; @@ -139,6 +139,8 @@ const hashEntry = ( * Walks a plugin tree the way the installers copy it: symlinks and special * files are refused, directories recurse in locale order, and the receipt at * the root is skipped so an installed copy hashes like the artifact it came from. + * Only regular files are content: an empty directory is neither hashed nor + * installed, so adding one to an artifact changes nothing. */ export const treeInventory = async (root: string): Promise => { const rootMetadata = await lstat(root); @@ -481,16 +483,18 @@ export const stageArtifact = async (options: { const parent = await mkdtemp(join(options.stageRoot, `.${basename(options.destination)}.stage-`)); const root = join(parent, 'bundle'); try { - // Runtime roots and a stray receipt in the artifact are never copied at all (a run-in-place - // artifact may hold a large live database). + // Exactly the inventoried content is copied: runtime roots and a stray receipt never are (a + // run-in-place artifact may hold a large live database), and neither are empty directories — + // they carry no plugin content, so they are not hashed, not installed, and not owned, and the + // installed tree, its receipt, and the artifact hash all describe the same set of entries. const artifactRoot = resolve(options.artifactRoot); + const source = await treeInventory(artifactRoot); + const content = new Set([...source.files, ...directoriesOf(source.files)]); await cp(artifactRoot, root, { errorOnExist: true, - filter: (source) => { - const relativePath = relative(artifactRoot, source); - if (relativePath === '') return true; - const top = relativePath.split(sep)[0] ?? ''; - return top !== installReceiptFile && !isPreservedRuntimeRoot(top); + filter: (entry) => { + const relativePath = relative(artifactRoot, entry); + return relativePath === '' || content.has(toPosix(relativePath)); }, force: false, recursive: true, diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index 387143283..d52982a8b 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -176,7 +176,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { "import { createHash, randomUUID } from 'node:crypto';", "import { cp, lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, rmdir, writeFile } from 'node:fs/promises';", "import { homedir } from 'node:os';", - "import { basename, dirname, join, relative, resolve, sep } from 'node:path';", + "import { basename, dirname, join, relative, resolve } from 'node:path';", "import { fileURLToPath } from 'node:url';", '', `const pluginName = ${name};`, @@ -364,16 +364,16 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { "}, null, 2) + '\\n';", '', '// Staged sibling copy on the destination filesystem so every later rename is atomic.', - 'const stage = async () => {', + 'const stage = async (tree) => {', ' const parent = await mkdtemp(join(installRoot, `.${basename(destination)}.stage-`));', " const root = join(parent, 'bundle');", ' try {', - ' // Runtime roots and a stray receipt in the artifact are never copied at all.', + ' // Exactly the inventoried content is copied: runtime roots, a stray receipt, and empty directories', + ' // (no plugin content: not hashed, not installed, not owned) never are.', + ' const content = new Set([...tree.files, ...directoriesOf(tree.files)]);', ' const filter = (path) => {', ' const relativePath = relative(source, path);', - " if (relativePath === '') return true;", - ' const top = relativePath.split(sep)[0];', - ' return top !== receiptFile && !isPreservedRoot(top);', + " return relativePath === '' || content.has(toPosix(relativePath));", ' };', ' await cp(source, root, { errorOnExist: true, filter, force: false, recursive: true, verbatimSymlinks: true });', ' const staged = await inventory(root);', @@ -415,7 +415,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { 'const artifact = await inventory(source);', '', 'if (!(await exists(destination))) {', - ' const staged = await stage();', + ' const staged = await stage(artifact);', ' try {', ' await rename(staged.root, destination);', ' console.log(`Installed ${pluginName}@${pluginVersion} at ${destination} (content ${short(artifact.hash)})`);', @@ -489,7 +489,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { '', '// 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();', + '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.', diff --git a/packages/agent-bundle/tests/install-surface.test.ts b/packages/agent-bundle/tests/install-surface.test.ts index 1b4a2f72e..d3eb63a4f 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -196,9 +196,13 @@ it('emitted install.mjs mirrors the core replace policy: no-op, owned-only repla expect(unknown.code).toBe(2); expect(unknown.stderr).toContain('Unknown installer argument "--bogus"'); + // Empty directories are not plugin content: never hashed, installed, or owned. + await mkdir(join(bundle, 'empty', 'nested'), { recursive: true }); const first = await run(installer, [], home); expect(first).toMatchObject({ code: 0, stderr: '' }); expect(first.stdout).toContain('Installed install-fixture@1.2.3'); + expect((await readdir(destination)).sort()).toEqual([installReceiptFile, '.cursor-plugin', 'INSTALL.md', 'install.mjs', 'payload.txt', 'removed-later.txt']); + await rm(join(bundle, 'empty'), { recursive: true }); const firstArtifact = await treeInventory(bundle); // The emitted receipt is byte-compatible with the core reader. expect(await readInstallReceipt(destination)).toMatchObject({ diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index dff2574b3..d804e597b 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -363,13 +363,17 @@ it('copies a Cursor bundle into a fake home and is idempotent', async () => { await mkdir(join(home, '.cursor')); const destination = join(home, '.cursor', 'plugins', 'local', 'install-fixture'); try { + // Empty directories are not plugin content: never hashed, installed, or owned. + await mkdir(join(fixture.bundleRoot, 'empty', 'nested'), { recursive: true }); const first = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); + await mkdir(join(fixture.bundleRoot, 'another-empty')); const second = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); const artifact = await treeInventory(fixture.bundleRoot); expect(first).toMatchObject({ contentHash: artifact.hash, destination, host: 'cursor', state: 'installed' }); expect(second).toMatchObject({ contentHash: artifact.hash, destination, host: 'cursor', state: 'already-installed' }); expect(await readFile(join(destination, 'payload.txt'), 'utf8')).toBe('payload\n'); + expect((await readdir(destination)).sort()).toEqual([installReceiptFile, '.cursor-plugin', 'payload.txt']); expect(await readInstallReceipt(destination)).toMatchObject({ contentHash: artifact.hash, // A fresh install created every directory, so it owns them all. From fa611bbf8f4cb67fe18d0a6016cdb5de5b675c24 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 13:21:04 +0000 Subject: [PATCH 19/22] fix(install): refuse artifact paths that cannot round-trip through a receipt (#400) --- docs/diagnostics.md | 5 ++- packages/agent-bundle/src/install/receipt.ts | 11 +++++-- packages/agent-bundle/src/install/surface.ts | 8 +++-- .../tests/install-surface.test.ts | 10 ++++++ packages/agent-bundle/tests/install.test.ts | 33 +++++++++++++++++++ 5 files changed, 62 insertions(+), 5 deletions(-) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 536cd32a2..f499cad58 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -731,7 +731,10 @@ are validated as strict POSIX-relative paths (no backslashes, no `..`/`.`/empty segments, no drive letters, nothing under a runtime root such as `state/`) before they can drive a deletion; a receipt that fails validation reads as absent, and a receipt that is not a regular file (a symbolic link, a -FIFO) is refused outright (`AB7004`) before it is read. +FIFO) is refused outright (`AB7004`) before it is read. The same rules apply to +the artifact itself: a file whose path could not round-trip through a receipt +(a backslash in a POSIX name, reserved characters, a trailing dot or space) is +refused (`AB7004`) before anything is staged. | Code | Severity | Trigger | Recovery | | --- | --- | --- | --- | diff --git a/packages/agent-bundle/src/install/receipt.ts b/packages/agent-bundle/src/install/receipt.ts index b673ad42b..df5adbfab 100644 --- a/packages/agent-bundle/src/install/receipt.ts +++ b/packages/agent-bundle/src/install/receipt.ts @@ -14,7 +14,7 @@ import { rmdir, writeFile, } from 'node:fs/promises'; -import { basename, dirname, join, relative, resolve } from 'node:path'; +import { basename, dirname, join, relative, resolve, sep } from 'node:path'; import { stableJson } from '../core/digest.ts'; import { isErrno } from '../core/errors.ts'; @@ -157,7 +157,14 @@ export const treeInventory = async (root: string): Promise => { for (const name of sortNames(await readdir(path))) await visit(join(relativePath, name)); return; } - files.push(toPosix(relativePath)); + // Every inventoried path must round-trip through a receipt unchanged: a POSIX name holding a + // backslash would be rewritten into a separator, and a name the receipt reader rejects + // (reserved characters, trailing dot or space, ...) could never be owned. Refuse them up front. + const posixPath = toPosix(relativePath); + if ((sep === '/' && relativePath.includes('\\')) || !isReceiptPath(posixPath)) { + throw unsupportedEntry(sep === '/' ? relativePath : posixPath); + } + files.push(posixPath); hashEntry(hash, relativePath, metadata, await readFile(path)); }; for (const name of sortNames(await readdir(root))) { diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index d52982a8b..46aff1b3f 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -176,7 +176,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { "import { createHash, randomUUID } from 'node:crypto';", "import { cp, lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, rmdir, writeFile } from 'node:fs/promises';", "import { homedir } from 'node:os';", - "import { basename, dirname, join, relative, resolve } from 'node:path';", + "import { basename, dirname, join, relative, resolve, sep } from 'node:path';", "import { fileURLToPath } from 'node:url';", '', `const pluginName = ${name};`, @@ -243,7 +243,11 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' for (const entry of (await readdir(absolute)).sort((left, right) => left.localeCompare(right))) await visit(join(relative, entry));', ' return;', ' }', - ' files.push(toPosix(relative));', + ' // Every inventoried path must round-trip through a receipt unchanged (no backslash in a POSIX', + ' // name, nothing the receipt reader rejects); refuse the rest up front.', + ' const posixPath = toPosix(relative);', + " if ((sep === '/' && relative.includes('\\\\')) || !safeRelative(posixPath)) throw unsupported(sep === '/' ? relative : posixPath);", + ' files.push(posixPath);', ' hashEntry(hash, relative, metadata, await readFile(absolute));', ' };', ' for (const entry of (await readdir(root)).sort((left, right) => left.localeCompare(right))) {', diff --git a/packages/agent-bundle/tests/install-surface.test.ts b/packages/agent-bundle/tests/install-surface.test.ts index d3eb63a4f..f3235b435 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -196,6 +196,16 @@ it('emitted install.mjs mirrors the core replace policy: no-op, owned-only repla expect(unknown.code).toBe(2); expect(unknown.stderr).toContain('Unknown installer argument "--bogus"'); + // An artifact path that could not round-trip through a receipt is refused before anything is staged. + if (process.platform !== 'win32') { + await writeFile(join(bundle, 'back\\slash.txt'), 'odd\n'); + const odd = await run(installer, [], home); + expect(odd.code).toBe(1); + expect(odd.stderr).toContain('Refusing unsupported filesystem entry "back\\\\slash.txt"'); + await expect(readdir(destination)).rejects.toMatchObject({ code: 'ENOENT' }); + await rm(join(bundle, 'back\\slash.txt')); + } + // Empty directories are not plugin content: never hashed, installed, or owned. await mkdir(join(bundle, 'empty', 'nested'), { recursive: true }); const first = await run(installer, [], home); diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index d804e597b..f326524c9 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -794,6 +794,39 @@ it('ignores receipts whose file list could escape the plugin root', async () => } }); +it('refuses artifact paths that could not round-trip through a receipt', async () => { + if (process.platform === 'win32') return; + 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 { + for (const name of ['back\\slash.txt', 'notes.md:stream', 'trailing.', 'trailing ', 'bad failure); + expect(refused, name).toBeInstanceOf(DiagnosticError); + expect((refused as DiagnosticError).diagnostics[0], name).toMatchObject({ code: 'AB7004', target: 'cursor' }); + await expect(access(destination), name).rejects.toMatchObject({ code: 'ENOENT' }); + await rm(join(fixture.bundleRoot, name)); + } + // Nested odd names are refused too, before anything is staged. + await mkdir(join(fixture.bundleRoot, 'skills', 'odd.'), { recursive: true }); + await writeFile(join(fixture.bundleRoot, 'skills', 'odd.', 'SKILL.md'), '# odd\n'); + await expect(treeInventory(fixture.bundleRoot)).rejects.toThrow('Refusing unsupported filesystem entry "skills/odd./SKILL.md"'); + await rm(join(fixture.bundleRoot, 'skills'), { recursive: true }); + expect(await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' })).toMatchObject({ state: 'installed' }); + } finally { + await Promise.all([ + rm(fixture.cleanupRoot, { force: true, recursive: true }), + rm(home, { force: true, recursive: true }), + ]); + } +}); + it('never lets a receipt claim runtime state: a receipt owning state/ reads as legacy and the store survives', async () => { const fixture = await createHostBundle('cursor'); const home = await mkdtemp(join(tmpdir(), 'agent-bundle-home-')); From ec29c9780f260c6a7a3349c6b94ef551b482901c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 13:32:48 +0000 Subject: [PATCH 20/22] fix(install): the receipt filename is reserved in every spelling (#400) --- packages/agent-bundle/src/install/receipt.ts | 4 +++- packages/agent-bundle/src/install/surface.ts | 3 ++- packages/agent-bundle/tests/install-surface.test.ts | 6 ++++++ packages/agent-bundle/tests/install.test.ts | 4 +++- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/agent-bundle/src/install/receipt.ts b/packages/agent-bundle/src/install/receipt.ts index df5adbfab..cccbeb391 100644 --- a/packages/agent-bundle/src/install/receipt.ts +++ b/packages/agent-bundle/src/install/receipt.ts @@ -281,7 +281,9 @@ export const hashOwnedFiles = async (root: string, files: readonly string[]): Pr export const isReceiptPath = (value: unknown): value is string => typeof value === 'string' && value.length > 0 && - value !== installReceiptFile && + // The receipt's own name is reserved in every spelling: on a case-insensitive filesystem an + // alias resolves to the receipt itself, so it can neither be owned content nor be installed. + value.toLowerCase() !== installReceiptFile.toLowerCase() && !value.includes('\\') && !value.startsWith('/') && // Runtime roots are never installer-owned, whatever a receipt claims and however it spells them. diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index 46aff1b3f..417cb6e87 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -317,7 +317,8 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { '', '// Receipt paths drive deletions: POSIX-relative only, no backslashes, no empty/./.. segments, and no', '// segment Windows would normalise onto another entry (reserved characters, colons, trailing dot/space).', - "const safeRelative = (value) => typeof value === 'string' && value.length > 0 && value !== receiptFile &&", + '// The receipt name is reserved in every spelling (a case alias resolves to the receipt itself).', + "const safeRelative = (value) => typeof value === 'string' && value.length > 0 && value.toLowerCase() !== receiptFile.toLowerCase() &&", " !value.includes('\\\\') && !value.startsWith('/') && !isPreservedRoot(value.split('/')[0]) &&", " value.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..' &&", " !/[<>:\"|?*]/u.test(segment) && [...segment].every((character) => character.charCodeAt(0) >= 0x20) &&", diff --git a/packages/agent-bundle/tests/install-surface.test.ts b/packages/agent-bundle/tests/install-surface.test.ts index f3235b435..eb5ad794e 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -204,6 +204,12 @@ it('emitted install.mjs mirrors the core replace policy: no-op, owned-only repla expect(odd.stderr).toContain('Refusing unsupported filesystem entry "back\\\\slash.txt"'); await expect(readdir(destination)).rejects.toMatchObject({ code: 'ENOENT' }); await rm(join(bundle, 'back\\slash.txt')); + // A case alias of the receipt filename is the receipt on a case-insensitive filesystem: refused. + await writeFile(join(bundle, '.Agent-Bundle-Install.json'), '{}\n'); + const alias = await run(installer, [], home); + expect(alias.code).toBe(1); + expect(alias.stderr).toContain('Refusing unsupported filesystem entry ".Agent-Bundle-Install.json"'); + await rm(join(bundle, '.Agent-Bundle-Install.json')); } // Empty directories are not plugin content: never hashed, installed, or owned. diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index f326524c9..711821227 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -750,6 +750,7 @@ it('ignores receipts whose file list could escape the plugin root', async () => try { for (const files of [ ['..\\outside'], ['../outside'], ['/etc/passwd'], ['a//b'], ['./x'], ['C:/x'], [installReceiptFile], + [installReceiptFile.toUpperCase()], ['.Agent-Bundle-Install.json'], ['notes.md:stream'], ['trailing.'], ['trailing '], ['bad Date: Thu, 3 Sep 2026 13:44:15 +0000 Subject: [PATCH 21/22] fix(install): reserve the receipt name as a top-level segment, file or directory (#400) --- packages/agent-bundle/src/install/receipt.ts | 7 ++++--- packages/agent-bundle/src/install/surface.ts | 6 ++++-- packages/agent-bundle/tests/install-surface.test.ts | 6 ++++++ packages/agent-bundle/tests/install.test.ts | 10 +++++++++- 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/packages/agent-bundle/src/install/receipt.ts b/packages/agent-bundle/src/install/receipt.ts index cccbeb391..8dc56a547 100644 --- a/packages/agent-bundle/src/install/receipt.ts +++ b/packages/agent-bundle/src/install/receipt.ts @@ -281,9 +281,10 @@ export const hashOwnedFiles = async (root: string, files: readonly string[]): Pr export const isReceiptPath = (value: unknown): value is string => typeof value === 'string' && value.length > 0 && - // The receipt's own name is reserved in every spelling: on a case-insensitive filesystem an - // alias resolves to the receipt itself, so it can neither be owned content nor be installed. - value.toLowerCase() !== installReceiptFile.toLowerCase() && + // The receipt's own name is reserved as a top-level entry in every spelling, file or directory: + // on a case-insensitive filesystem an alias resolves to the receipt itself, so nothing at or + // beneath it can be owned content or be installed. + (value.split('/')[0] ?? '').toLowerCase() !== installReceiptFile.toLowerCase() && !value.includes('\\') && !value.startsWith('/') && // Runtime roots are never installer-owned, whatever a receipt claims and however it spells them. diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index 417cb6e87..60c276c36 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -317,8 +317,10 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { '', '// Receipt paths drive deletions: POSIX-relative only, no backslashes, no empty/./.. segments, and no', '// segment Windows would normalise onto another entry (reserved characters, colons, trailing dot/space).', - '// The receipt name is reserved in every spelling (a case alias resolves to the receipt itself).', - "const safeRelative = (value) => typeof value === 'string' && value.length > 0 && value.toLowerCase() !== receiptFile.toLowerCase() &&", + '// The receipt name is reserved as a top-level entry in every spelling, file or directory (a case alias', + '// resolves to the receipt itself on case-insensitive filesystems).', + "const safeRelative = (value) => typeof value === 'string' && value.length > 0 &&", + " value.split('/')[0].toLowerCase() !== receiptFile.toLowerCase() &&", " !value.includes('\\\\') && !value.startsWith('/') && !isPreservedRoot(value.split('/')[0]) &&", " value.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..' &&", " !/[<>:\"|?*]/u.test(segment) && [...segment].every((character) => character.charCodeAt(0) >= 0x20) &&", diff --git a/packages/agent-bundle/tests/install-surface.test.ts b/packages/agent-bundle/tests/install-surface.test.ts index eb5ad794e..0ef730205 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -210,6 +210,12 @@ it('emitted install.mjs mirrors the core replace policy: no-op, owned-only repla expect(alias.code).toBe(1); expect(alias.stderr).toContain('Refusing unsupported filesystem entry ".Agent-Bundle-Install.json"'); await rm(join(bundle, '.Agent-Bundle-Install.json')); + await mkdir(join(bundle, '.Agent-Bundle-Install.json')); + await writeFile(join(bundle, '.Agent-Bundle-Install.json', 'payload'), 'odd\n'); + const aliasDirectory = await run(installer, [], home); + expect(aliasDirectory.code).toBe(1); + expect(aliasDirectory.stderr).toContain('Refusing unsupported filesystem entry ".Agent-Bundle-Install.json/payload"'); + await rm(join(bundle, '.Agent-Bundle-Install.json'), { recursive: true }); } // Empty directories are not plugin content: never hashed, installed, or owned. diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index 711821227..2697e8f6b 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -750,7 +750,8 @@ it('ignores receipts whose file list could escape the plugin root', async () => try { for (const files of [ ['..\\outside'], ['../outside'], ['/etc/passwd'], ['a//b'], ['./x'], ['C:/x'], [installReceiptFile], - [installReceiptFile.toUpperCase()], ['.Agent-Bundle-Install.json'], + [installReceiptFile.toUpperCase()], ['.Agent-Bundle-Install.json'], [`${installReceiptFile}/nested`], + ['.Agent-Bundle-Install.json/payload'], ['notes.md:stream'], ['trailing.'], ['trailing '], ['bad Date: Thu, 3 Sep 2026 13:56:10 +0000 Subject: [PATCH 22/22] fix(install): reject Windows device names in receipt and artifact paths (#400) --- packages/agent-bundle/src/install/receipt.ts | 6 +++++- packages/agent-bundle/src/install/surface.ts | 7 +++++-- packages/agent-bundle/tests/install.test.ts | 3 ++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/agent-bundle/src/install/receipt.ts b/packages/agent-bundle/src/install/receipt.ts index 8dc56a547..341729d9d 100644 --- a/packages/agent-bundle/src/install/receipt.ts +++ b/packages/agent-bundle/src/install/receipt.ts @@ -276,8 +276,11 @@ export const hashOwnedFiles = async (root: string, files: readonly string[]): Pr * emits: POSIX-relative, no backslashes (a Windows `..\outside` must not slip * past POSIX normalization), no empty, `.`, or `..` segments, no drive letter, * and no segment Windows would normalise onto another entry (reserved - * characters, alternate-stream colons, trailing dots or spaces). + * characters, alternate-stream colons, trailing dots or spaces) or resolve as + * a DOS device (`NUL`, `CON.txt`, `COM1`, `LPT1.json`, ...). */ +const windowsDeviceName = /^(?:con|prn|aux|nul|com[0-9¹²³]|lpt[0-9¹²³])(?:\.|$)/iu; + export const isReceiptPath = (value: unknown): value is string => typeof value === 'string' && value.length > 0 && @@ -294,6 +297,7 @@ export const isReceiptPath = (value: unknown): value is string => segment !== '.' && segment !== '..' && !/[<>:"|?*]/u.test(segment) && + !windowsDeviceName.test(segment) && [...segment].every((character) => character.charCodeAt(0) >= 0x20) && !segment.endsWith('.') && !segment.endsWith(' ')); diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index 60c276c36..ad56d5b63 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -316,14 +316,17 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { '};', '', '// Receipt paths drive deletions: POSIX-relative only, no backslashes, no empty/./.. segments, and no', - '// segment Windows would normalise onto another entry (reserved characters, colons, trailing dot/space).', + '// segment Windows would normalise onto another entry (reserved characters, colons, trailing dot/space)', + '// or resolve as a DOS device (NUL, CON.txt, COM1, LPT1.json, ...).', + 'const windowsDeviceName = /^(?:con|prn|aux|nul|com[0-9¹²³]|lpt[0-9¹²³])(?:\\.|$)/iu;', '// The receipt name is reserved as a top-level entry in every spelling, file or directory (a case alias', '// resolves to the receipt itself on case-insensitive filesystems).', "const safeRelative = (value) => typeof value === 'string' && value.length > 0 &&", " value.split('/')[0].toLowerCase() !== receiptFile.toLowerCase() &&", " !value.includes('\\\\') && !value.startsWith('/') && !isPreservedRoot(value.split('/')[0]) &&", " value.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..' &&", - " !/[<>:\"|?*]/u.test(segment) && [...segment].every((character) => character.charCodeAt(0) >= 0x20) &&", + " !/[<>:\"|?*]/u.test(segment) && !windowsDeviceName.test(segment) &&", + ' [...segment].every((character) => character.charCodeAt(0) >= 0x20) &&', " !segment.endsWith('.') && !segment.endsWith(' '));", '', 'const readReceipt = async (root) => {', diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index 2697e8f6b..2026462ad 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -753,6 +753,7 @@ it('ignores receipts whose file list could escape the plugin root', async () => [installReceiptFile.toUpperCase()], ['.Agent-Bundle-Install.json'], [`${installReceiptFile}/nested`], ['.Agent-Bundle-Install.json/payload'], ['notes.md:stream'], ['trailing.'], ['trailing '], ['bad