diff --git a/.changeset/400-install-replace.md b/.changeset/400-install-replace.md new file mode 100644 index 000000000..27cf02ad1 --- /dev/null +++ b/.changeset/400-install-replace.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +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/docs/diagnostics.md b/docs/diagnostics.md index d808bc684..f499cad58 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,75 @@ 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": "", + "directories": [".cursor-plugin", "..."], + "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, and neither do empty +directories or runtime roots (`state/`): only regular files are plugin content, +so the artifact hash, the installed tree, and the receipt always describe the +same entries. Ownership of an existing +destination is decided as **receipt** (a receipt naming this plugin), **legacy** +(no receipt, but the emitted `INSTALL.md` + `install.mjs` and a manifest with +this plugin's name — a copy installed before receipts existed), or **foreign** +(anything else). Claude and Codex copies are located through the host's own +`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 | +| --- | --- | --- | --- | +| Identical content (receipt / host-managed) | `already-installed` no-op | `already-installed` no-op | `current` | +| Identical content (legacy) | `already-installed` no-op | `adopted` — receipt written, no plugin file changes | `current` | +| Receipt / host-managed, same version, different content | replaced automatically (`replaced`) | replaced | `stale` — `AB7308` warning | +| Receipt / host-managed, different version | `AB7005` version collision | replaced | `version-mismatch` — `AB7309` warning | +| Legacy, different content | `AB7005` content collision | adopted: the artifact's files are rewritten, every other file is left in place and stays unowned, receipt written (`replaced`) | `stale` — `AB7308` warning, recovery names `--replace` | +| Foreign directory | `AB7005` foreign install | `AB7005` foreign install | `foreign` — `AB7321` warning | +| 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 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 +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 | +| --- | --- | --- | --- | +| `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..c66039244 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 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 +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..e47227cdc 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,67 @@ 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, 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 + `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; 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 + 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). 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 + 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..93d320cba 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,63 @@ const writeHumanPrepack = (output: Output, result: Awaited { + switch (state) { + case 'adopted': + return 'Adopted'; + 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 +354,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 +626,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 +641,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..157e80982 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,32 @@ const diagnosticsFor = (error: unknown): readonly Diagnostic[] => severity: 'error' as const, })]); +const stateLabel = (state: InstallResult['state']): string => { + switch (state) { + case 'adopted': + return 'Adopted'; + 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 +74,7 @@ const isScope = (value: string): value is InstallScope => interface ParsedInstallArguments { readonly host: InstallHost; readonly json: boolean; + readonly replace: boolean; readonly scope: InstallScope; } @@ -66,6 +92,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 +100,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 +115,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 +142,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..006ec174d 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[]; @@ -717,20 +758,111 @@ 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['scope'] !== 'string' || + typeof row['version'] !== 'string' + ) { + return unknown('a row lacks id, installPath, scope, or version'); + } + findings.push({ + entry: `${row['id']} (${row['scope']})`, + 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, @@ -752,6 +884,161 @@ 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, +}); + +/** + * 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 = ( + host: Exclude, + identity: PluginIdentity, + listing: PublicHostListing, + environment: Readonly, + home: string, +): 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 ` + + '`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([]), + }; + } + if (inventory.entries.length === 0) { + 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, + )]), + }; + } + // 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, + installedVersion: entry.version, + ownership: 'host', + status: 'unknown', + })); + diagnostics.push(diagnostic( + 'AB7310', + `${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 ${replaceHint} --replace\`.`, + 'error', + host, + )); + continue; + } + const status: 'current' | 'stale' | 'version-mismatch' = + installed.hash === artifact.hash && entry.version === identity.version + ? 'current' + : entry.version !== identity.version + ? 'version-mismatch' + : 'stale'; + comparisons.push(Object.freeze({ + artifactContentHash: artifact.hash, + installedContentHash: installed.hash, + installedPath: entry.installPath, + installedVersion: entry.version, + ownership: 'host', + status, + })); + const detail = describeContentComparison(identity.name, identity.version, { + artifactContentHash: artifact.hash, + installedContentHash: installed.hash, + installedName: identity.name, + installedVersion: entry.version, + status, + }); + switch (status) { + case 'current': + break; + case 'stale': + diagnostics.push(diagnostic( + 'AB7308', + `${host} plugin ${identity.name}@${identity.version} at ${entry.installPath}${scoped} is stale ` + + `(same version, different content): ${detail}.`, + publicHostReplaceRecipe(host, replaceHint), + 'warning', + host, + )); + break; + case 'version-mismatch': + diagnostics.push(diagnostic( + 'AB7309', + `${host} version collision at ${entry.installPath}${scoped}: ${detail}.`, + `Rerun \`agent-bundle install ${host} --from ${replaceHint} --replace\` to replace the installed version.`, + 'warning', + host, + )); + 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 ( identity: PluginIdentity, home: string, @@ -764,7 +1051,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,51 +1061,84 @@ 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', + }), }; } + // 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, 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' }), - }; - } - 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, + ...(installed === undefined + ? {} + : { + 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 +1153,7 @@ const cursorBundle = async ( } }; -const claudeBundle = async ( +const claudeRegistration = async ( identity: PluginIdentity, probe: DoctorHostProbe, run: DoctorCommandRunner, @@ -928,24 +1248,78 @@ const claudeBundle = async ( return { diagnostics: Object.freeze([]), finding: Object.freeze({ ...base, state: 'registered' }) }; }; -const codexBundle = ( +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; +} + +/** Claude: inline registration proof plus the installed cache copy compared against the artifact. */ +const claudeBundle = 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 registration = await claudeRegistration(identity, probe, context.run); + if (probe.status !== 'available' || registration.finding === undefined) return registration; + const artifact = await treeInventory(identity.bundleRoot); + 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]), + 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, + 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 = readPublicHostInventory('codex', identity, context.listing, 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.entries.length === 0 ? 'missing' : 'installed', + }), + }; +}; type EndpointProbe = 'live' | 'missing' | 'stale'; @@ -1298,9 +1672,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) { @@ -1312,11 +1692,12 @@ const doctorHost = async ( identity.bundleRoot, await validateBundleFiles(identity.bundleRoot, host), ); + const context: PublicHostContext = { environment, home, listing, 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..023c64b51 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 { dirname, join, resolve } from 'node:path'; import { Effect, Predicate } from 'effect'; @@ -20,9 +10,26 @@ 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, + createInstallReceipt, + describeContentComparison, + replaceInstalledTree, + stageArtifact, + treeInventory, + writeInstallReceipt, + type InstalledManifestIdentity, + type InstalledTreeComparison, + type TreeInventory, +} from './receipt.ts'; export type InstallHost = 'claude' | 'codex' | 'cursor'; export type InstallScope = 'local' | 'project' | 'user'; +/** + * `adopted`: a byte-identical pre-receipt Cursor copy gained its receipt under + * `--replace`; no plugin file changed. + */ +export type InstallResultState = 'adopted' | 'already-installed' | 'installed' | 'replaced'; export interface InstallCommandResult { readonly code: number; @@ -40,19 +47,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 +208,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 +221,147 @@ 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; + /** 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. 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 entries: readonly 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 rows = document.filter((candidate): candidate is Record => + isRecord(candidate) && candidate['id'] === id); + const entries: PublicHostInstalledEntry[] = []; + 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' || + 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'], version: row['version'] }); + } + 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 { 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 { + entries: [{ + 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 +376,75 @@ 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; + // 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); + } 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; + if (installed !== undefined && sameVersion && installed.hash === artifact.hash) { + return { ...base, destination: entry.installPath, state: 'already-installed' }; + } + if (!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'); + replaced = true; + previousContentHash = installed?.hash; + } + } await runHostCommand(runner, identity, host, [ 'plugin', 'marketplace', @@ -226,61 +452,62 @@ 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, + ...(destination === undefined ? {} : { destination }), + ...(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 +531,67 @@ 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', - ); + 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 }); } - if (await treeHash(identity.bundleRoot) === await treeHash(destination)) { - return { - bundleRoot: identity.bundleRoot, - destination, + return { ...base, contentHash: artifact.hash, state: 'installed' }; + } + if (resolve(identity.bundleRoot) === destination) { + return { ...base, contentHash: artifact.hash, state: 'already-installed' }; + } + const comparison = await compareInstalledTree({ + artifact, + destination, + installedManifest: await readInstalledManifest(destination), + plugin: identity.plugin, + version: identity.version, + }); + 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, - state: 'already-installed', version: identity.version, - }; + })); + return { ...base, contentHash: artifact.hash, state: 'adopted' }; } - throw failure('AB7005', `Refusing content collision at ${destination}.`, 'cursor'); + 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 stageParent = await mkdtemp(join(installRoot, `.${basename(destination)}.stage-`)); - const stage = join(stageParent, 'bundle'); + const staged = await stageArtifact({ artifactRoot: identity.bundleRoot, destination, receipt, stageRoot: installRoot }); try { - await cp(identity.bundleRoot, stage, { - errorOnExist: true, - force: false, - recursive: true, - verbatimSymlinks: true, - }); - await treeHash(stage); - await rename(stage, destination); + await replaceInstalledTree({ comparison, destination, receipt, staged }); } finally { - await rm(stageParent, { force: true, recursive: true }); + await rm(staged.parent, { force: true, recursive: true }); } return { - bundleRoot: identity.bundleRoot, - destination, - host: 'cursor', - plugin: identity.plugin, - state: 'installed', - version: identity.version, + ...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..341729d9d --- /dev/null +++ b/packages/agent-bundle/src/install/receipt.ts @@ -0,0 +1,714 @@ +import { createHash, randomUUID } from 'node:crypto'; +import type { Stats } from 'node:fs'; +import { + cp, + lstat, + mkdir, + mkdtemp, + open, + readFile, + readdir, + realpath, + rename, + rm, + rmdir, + writeFile, +} from 'node:fs/promises'; +import { basename, dirname, join, relative, resolve, sep } from 'node:path'; + +import { stableJson } from '../core/digest.ts'; +import { isErrno } from '../core/errors.ts'; +import { exists } 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']); + +/** + * 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 + * 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; + /** + * 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; + 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('\\', '/'); + +/** 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 + * 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'); +}; + +/** + * 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); + 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; + } + // 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))) { + if (name === installReceiptFile) { + // 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, + // whether they sit in an installed copy or in an artifact that was run in place. + if (isPreservedRuntimeRoot(name)) continue; + await visit(name); + } + 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. 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, +): 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 { + canonical ??= await realpath(join(root, relativePath)); + if (await realpath(join(root, candidate)) === canonical) 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 + * outside the plugin root (development installs point top-level directories + * 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[], + ownedFiles: ReadonlySet = new Set(), +): 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()) throw unsupportedEntry(directory); + if ( + !metadata.isDirectory() && + !(metadata.isFile() && await isOwnedEntry(root, ownedFiles, directory)) + ) { + 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); + 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, metadata, await readFile(path)); + } + 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, + * and no segment Windows would normalise onto another entry (reserved + * 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 && + // 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. + !isPreservedRuntimeRoot(value.split('/')[0] ?? '') && + value.split('/').every((segment) => + segment !== '' && + segment !== '.' && + segment !== '..' && + !/[<>:"|?*]/u.test(segment) && + !windowsDeviceName.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); + +/** + * Reads the receipt at a plugin root; malformed or unsafe receipts read as + * 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)).isFile()) 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; + } + 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']) || + !isReceiptFileList(record['directories']) + ) { + return undefined; + } + return Object.freeze({ + contentHash: record['contentHash'], + directories: Object.freeze([...record['directories']]), + files: Object.freeze([...record['files']]), + format: installReceiptFormat, + host: record['host'], + installedAt: record['installedAt'], + plugin: record['plugin'], + version: record['version'], + }); +}; + +/** + * Builds a receipt for an inventory. `directories` defaults to every ancestor + * of the inventoried files — right for a fresh install, where the installer + * created all of them; callers that adopt or replace an existing tree pass + * exactly the directories they created. + */ +export const createInstallReceipt = (options: { + readonly directories?: readonly string[]; + readonly host: string; + readonly installedAt?: string; + readonly inventory: TreeInventory; + readonly plugin: string; + readonly version: string; +}): InstallReceipt => Object.freeze({ + contentHash: options.inventory.hash, + directories: options.directories ?? directoriesOf(options.inventory.files), + 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`; + +/** + * 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}.${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 }); + } +}; + +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); + // 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])); + // 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'; + 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 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})`; +}; + +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 { + // 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: (entry) => { + const relativePath = relative(artifactRoot, entry); + return relativePath === '' || content.has(toPosix(relativePath)); + }, + force: false, + recursive: true, + verbatimSymlinks: 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; + } +}; + +/** + * 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; + } + } +}; + +/** + * What the previous installer owned. A receipt says so exactly. A legacy copy + * has no inventory, so only the files the new artifact also ships count as + * owned: they are rewritten, everything else (operator files, stale artifact + * files, `state/`) is left in place and stays unowned under the new receipt. + */ +const previouslyOwnedFiles = async ( + destination: string, + comparison: InstalledTreeComparison, + incoming: ReadonlySet, +): Promise => { + if (comparison.ownership === 'receipt' && comparison.receipt !== undefined) return comparison.receipt.files; + const inventory = await treeInventory(destination); + const owned: string[] = []; + for (const file of inventory.files) { + // Exact match, or a case alias of an incoming path on case-insensitive filesystems. + if (await isOwnedEntry(destination, incoming, file)) owned.push(file); + } + return owned; +}; + +/** + * True when a directory is entirely previous-installer territory: the + * directory and every directory beneath it were created by the installer, it + * 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) { + 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() || !await isOwnedEntry(destination, owned, relative)) return false; + files += 1; + } + return true; + }; + return await visit(relativePath) && files > 0; +}; + +/** + * Directories the previous installer created. A receipt says so exactly; a + * legacy copy has no inventory, so none of its directories are ours. + */ +const previouslyOwnedDirectories = (comparison: InstalledTreeComparison): readonly string[] => + comparison.ownership === 'receipt' && comparison.receipt !== undefined ? comparison.receipt.directories : []; + +/** + * Replaces an agent-bundle-owned install in place: stale owned files leave + * first (their now-empty installer-created directories are pruned), every + * 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') { + throw new Error(`Refusing to replace foreign install at ${options.destination}.`); + } + const incoming = new Set(options.staged.inventory.files); + const previouslyOwned = await previouslyOwnedFiles(options.destination, options.comparison, incoming); + const 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 + // 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; + const target = join(options.destination, 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(options.destination, file, owned, ownedDirectories) + : metadata.isFile() && await isOwnedEntry(options.destination, owned, file); + if (!tolerated) 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 }); + } + const pruned = await pruneEmptyDirectories(options.destination, stale, ownedDirectories); + const created = new Set(); + for (const file of options.staged.inventory.files) { + 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 ed89ce424..ad56d5b63 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,414 @@ 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 { 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, join, resolve } from 'node:path';", + "import { basename, dirname, join, relative, resolve, sep } 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)};`, + '// 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');", "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 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) => {', + ' hash.update(toPosix(relative));', + " hash.update('\\0');", + " hash.update((metadata.mode & 0o111) === 0 ? '-' : 'x');", + " hash.update('\\0');", + ' hash.update(bytes);', + " hash.update('\\0');", + '};', '', - 'const treeHash = async (root, prefix = \'\') => {', + '// 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');", + ' // 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 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) {', + ' // 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.', + ' if (isPreservedRoot(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 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 {', + ' 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;', + '};', + '', + '// 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);', + " while (directory !== '.' && directory !== '') {", + ' if (!checked.has(directory)) {', + ' checked.add(directory);', + ' try {', + ' const metadata = await lstat(join(root, directory));', + ' if (metadata.isSymbolicLink()) throw unsupported(directory);', + ' if (!metadata.isDirectory() && !(metadata.isFile() && await isOwnedEntry(root, ownedFiles, directory))) {', + ' 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);', + ' 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, metadata, await readFile(absolute));', + ' }', " return hash.digest('hex');", '};', '', - 'const installedVersion = async () => {', + '// 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)', + '// 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) && !windowsDeviceName.test(segment) &&", + ' [...segment].every((character) => character.charCodeAt(0) >= 0x20) &&', + " !segment.endsWith('.') && !segment.endsWith(' '));", + '', + 'const readReceipt = async (root) => {', + ' let value;', + ' try {', + ' 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;", + ' // 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) ||', + ' !Array.isArray(value.directories) || !value.directories.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;', + '};', + '', + '// 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',", + ' 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 (tree) => {', + ' const parent = await mkdtemp(join(installRoot, `.${basename(destination)}.stage-`));', + " const root = join(parent, 'bundle');", + ' try {', + ' // 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);', + " return relativePath === '' || content.has(toPosix(relativePath));", + ' };', + ' 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 };', + ' } catch (error) {', + ' await rm(parent, { force: true, recursive: true });', + ' throw error;', + ' }', + '};', + '', + '// 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()) {', ' 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}.`);', + 'const artifact = await inventory(source);', + '', + 'if (!(await exists(destination))) {', + ' const staged = await stage(artifact);', + ' 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 });', ' }', - ' if (source === destination || await treeHash(source) === await treeHash(destination)) {', - ' console.log(`Already installed ${pluginName}@${pluginVersion} at ${destination}`);', + ' process.exit(0);', + '}', + 'if (source === destination) {', + ' console.log(`Already installed ${pluginName}@${pluginVersion} at ${destination} (content ${short(artifact.hash)})`);', + ' process.exit(0);', + '}', + '', + '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);", + '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]));', + 'if (installedHash === artifact.hash && inventoryMatches) {', + " if (ownership === 'legacy' && replace) {", + ' // 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'); // adoption created no directories", + ' 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);', ' }', - ' throw new Error(`Refusing content collision at ${destination}.`);', + ' console.log(`Already installed ${pluginName}@${pluginVersion} at ${destination} (content ${short(artifact.hash)})`);', + ' process.exit(0);', + '}', + 'if (installedVersion !== undefined && installedVersion !== pluginVersion && !replace) {', + ' throw new Error(`Refusing version collision at ${destination}: ${detail}. Re-run with --replace to replace this agent-bundle install.`);', + '}', + "if (ownership === 'legacy' && !replace) {", + ' 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.');", '}', '', - 'const stageParent = await mkdtemp(join(installRoot, `.${basename(destination)}.stage-`));', - "const stage = join(stageParent, 'bundle');", + '// Owned-files-only replacement: stale owned files leave first, staged files rename over their', + '// predecessors, and the receipt lands last as the commit marker. Unowned entries (runtime state) stay.', + 'const staged = await stage(artifact);', 'try {', - ' 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}`);', + ' // A legacy copy has no inventory: only files the new artifact also ships count as owned; everything', + ' // else (operator files, stale artifact files, runtime state) stays in place and remains unowned.', + ' const incoming = new Set(staged.inventory.files);', + ' let owned;', + " if (ownership === 'receipt') {", + ' owned = receipt.files;', + ' } else {', + ' owned = [];', + ' for (const file of (await inventory(destination)).files) {', + ' if (await isOwnedEntry(destination, incoming, file)) owned.push(file);', + ' }', + ' }', + ' const 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: 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) {', + ' 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() || !(await isOwnedEntry(destination, ownedSet, relative))) return false;', + ' files += 1;', + ' }', + ' return true;', + ' };', + ' return (await visit(relativePath)) && files > 0;', + ' };', + ' const collisions = [];', + ' for (const file of staged.inventory.files) {', + ' if (ownedSet.has(file)) continue;', + ' const target = join(destination, 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)', + ' : metadata.isFile() && await isOwnedEntry(destination, ownedSet, file);', + ' 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.`);", + ' }', + ' const stale = owned.filter((file) => !incoming.has(file));', + ' for (const file of stale) await rm(join(destination, file), { force: true });', + ' const pruned = await pruneEmptyDirectories(stale, ownedDirectories);', + ' const created = new Set();', + ' for (const file of staged.inventory.files) {', + ' 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 {', - ' 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..19f29c3d1 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,8 +720,10 @@ it('validates --from Claude documents from pinned bytes without a new CLI proof' hosts: ['claude'], }); + // 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', @@ -769,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({ @@ -780,6 +792,82 @@ 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(); + } +}); + +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(); } @@ -846,6 +934,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 { @@ -888,7 +1069,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'); @@ -901,10 +1082,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(); } @@ -1001,6 +1186,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 +1195,147 @@ 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' }), + ])); + + // 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(); + } +}); + +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..0ef730205 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, symlink, 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,308 @@ 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'); + expect(installer).toContain('Refusing to overwrite unowned files'); + expect(installer).toContain("!value.includes('\\\\')"); +}); + +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 }, + // 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) { + 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"'); + + // 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')); + // 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')); + 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. + 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({ + contentHash: firstArtifact.hash, + directories: ['.cursor-plugin'], + 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'); + + // 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', + }); + + // 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'); + + // 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); + 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; + 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'); + + // 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)); + 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'); + 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..2026462ad 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -1,10 +1,17 @@ -import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +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'; 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 +21,9 @@ interface CommandCall { readonly cwd: string; } -const recordingRunner = (): { +const recordingRunner = ( + respond: (call: CommandCall) => string = () => '', +): { readonly calls: CommandCall[]; readonly runner: InstallCommandRunner; } => { @@ -23,18 +32,35 @@ 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`); }; +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 } = {}, @@ -84,6 +110,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 +122,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 +135,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 +148,152 @@ 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, + destination: installed, + 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', + ]); + + // 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 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 }); + } +}); + +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 { + // 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 }); + 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', + }); + 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', + ]); + + // 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 }); + } +}); + it('accepts an artifact root containing the requested host target', async () => { const fixture = await createHostBundle('claude', { artifactRoot: true }); const { calls, runner } = recordingRunner(); @@ -184,12 +363,173 @@ 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' }); - 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 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. + directories: ['.cursor-plugin'], + 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, + directories: ['.cursor-plugin', 'skills', 'skills/new'], + 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(); + + // 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' }) + .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 }), + 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 }); + + // 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' }); + // Adoption created no directories, so the legacy copy's directories are never pruned. + expect(await readInstallReceipt(destination)).toMatchObject({ directories: [], plugin: 'install-fixture', version: '1.2.3' }); + await rm(join(destination, installReceiptFile)); + + await writeFile(join(destination, 'payload.txt'), 'stale\n'); + // Operator content and a file the rebuild dropped: a legacy copy has no inventory, so both stay. + await writeFile(join(destination, 'operator-note.txt'), 'keep me\n'); + await writeFile(join(destination, 'dropped-by-rebuild.txt'), 'old artifact file\n'); + const legacyHash = (await treeInventory(destination)).hash; + const artifact = await treeInventory(fixture.bundleRoot); + + const refused = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }) + .catch((failure: unknown) => failure); + expect(refused).toBeInstanceOf(DiagnosticError); + const message = (refused as DiagnosticError).diagnostics[0]?.message ?? ''; + expect((refused as DiagnosticError).diagnostics[0]).toMatchObject({ code: 'AB7005', target: 'cursor' }); + expect(message).toContain('Refusing content collision'); + expect(message).toContain(`content ${legacyHash.slice(0, 12)}`); + expect(message).toContain(`content ${artifact.hash.slice(0, 12)}`); + expect(message).toContain('same version, different content'); + expect(message).toContain('--replace'); + + const replaced = await installBundle({ from: fixture.from, home, host: 'cursor', replace: true, scope: 'user' }); + expect(replaced).toMatchObject({ contentHash: artifact.hash, previousContentHash: legacyHash, state: 'replaced' }); expect(await readFile(join(destination, 'payload.txt'), 'utf8')).toBe('payload\n'); + expect(await readFile(join(destination, 'operator-note.txt'), 'utf8')).toBe('keep me\n'); + expect(await readFile(join(destination, 'dropped-by-rebuild.txt'), 'utf8')).toBe('old artifact file\n'); + const receipt = await readInstallReceipt(destination); + expect(receipt).toMatchObject({ contentHash: artifact.hash, directories: [], plugin: 'install-fixture' }); + expect(receipt?.files).toEqual(artifact.files); + // From now on the leftovers are unowned: a later same-version replace leaves them alone. + await writeFile(join(fixture.bundleRoot, 'payload.txt'), 'rebuilt\n'); + await 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 }), @@ -222,35 +562,415 @@ it('fails closed when Cursor is not detected in the selected home', async () => } }); -it('refuses Cursor version and content collisions', 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' }); - await writeFile(join(destination, 'payload.txt'), 'changed\n'); - const contentError = await installBundle({ - from: fixture.from, - home, + + // 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'); + + // 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' }); + 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'); + 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'); + 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 }); + + // 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')); + 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-')); + 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'); + + 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', + directories: [], + files: ['payload.txt'], + format: 'agent-bundle-install-receipt/1', host: 'cursor', - scope: 'user', - }).catch((failure: unknown) => failure); - expect(contentError).toBeInstanceOf(DiagnosticError); - expect((contentError as DiagnosticError).diagnostics).toMatchObject([{ code: 'AB7005' }]); + 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' }); - await writeJson(join(destination, '.cursor-plugin/plugin.json'), { - name: 'install-fixture', - version: '9.0.0', - }); - const versionError = await installBundle({ - from: fixture.from, - home, + // 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')); + 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 { + for (const files of [ + ['..\\outside'], ['../outside'], ['/etc/passwd'], ['a//b'], ['./x'], ['C:/x'], [installReceiptFile], + [installReceiptFile.toUpperCase()], ['.Agent-Bundle-Install.json'], [`${installReceiptFile}/nested`], + ['.Agent-Bundle-Install.json/payload'], + ['notes.md:stream'], ['trailing.'], ['trailing '], ['bad failure); + installedAt: '2026-09-03T00:00:00.000Z', + plugin: 'install-fixture', + version: '1.2.3', + }; + 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({ + directories: ['skills', 'skills/probe'], + files: ['skills/probe/SKILL.md', 'plugin.json'], + }); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +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 { + // Includes a case alias of the receipt filename: on a case-insensitive filesystem it is the receipt. + 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 }); + // So is a directory spelled like the receipt: on a case-insensitive filesystem it is the receipt's path. + await mkdir(join(fixture.bundleRoot, '.Agent-Bundle-Install.json')); + await writeFile(join(fixture.bundleRoot, '.Agent-Bundle-Install.json', 'payload'), 'odd\n'); + await expect(treeInventory(fixture.bundleRoot)).rejects.toThrow( + 'Refusing unsupported filesystem entry ".Agent-Bundle-Install.json/payload"', + ); + await rm(join(fixture.bundleRoot, '.Agent-Bundle-Install.json'), { 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-')); + 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-')); + 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, even byte-identical. + await rm(destination, { force: true, recursive: true }); + await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); + 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'); + expect((otherError as DiagnosticError).diagnostics[0]?.message).toContain('installed other-plugin@1.2.3'); + + // 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 +1063,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 +1088,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, }),