diff --git a/.changeset/foreign-destination-inventory.md b/.changeset/foreign-destination-inventory.md new file mode 100644 index 000000000..8d0c26b94 --- /dev/null +++ b/.changeset/foreign-destination-inventory.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Refuse a foreign destination and a same-version marketplace restage from `install.mjs` when the destination lacks paths listed in `agent-bundle.manifest.json`, instead of crashing with `ENOENT`. `uninstall --force` on a pre-receipt copy removes the files present in that copy, matching the framework CLI. (#818) diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index 88d610faa..5e3302f96 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -1036,7 +1036,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { '// Deterministic tree walk: symlinks and special files refused, the root receipt skipped. `transform`', '// maps a file\'s bytes to what the Cursor copy will hold (the Agent Plugins mcp.json expansion below), so', '// the artifact hash describes the installed form and reruns compare like for like.', - 'const inventory = async (root, transform) => {', + 'const readTree = async (root, transform, selectedPaths) => {', ' const rootMetadata = await lstat(root);', " if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) throw unsupported('.');", " const hash = createHash('sha256');", @@ -1057,10 +1057,11 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' const bytes = await readFile(absolute);', ' hashEntry(hash, relative, metadata, transform === undefined ? bytes : transform(posixPath, bytes));', ' };', - ' if (artifactManifest !== undefined) {', - " const selected = new Set(['agent-bundle.manifest.json', ...artifactManifest.files.map((file) => file.path)]);", - " for (const file of ['.env', '.env.local']) if (await exists(join(root, file))) selected.add(file);", - ' for (const file of [...selected].sort(compareTreePaths)) await visit(file);', + ' if (selectedPaths !== undefined) {', + ' for (const file of selectedPaths) {', + ' if (!(await exists(join(root, file)))) throw new Error(`bundle does not match its manifest: ${file} is missing.`);', + ' await visit(file);', + ' }', " return { files, hash: hash.digest('hex') };", ' }', ' for (const entry of (await readdir(root)).sort((left, right) => left.localeCompare(right))) {', @@ -1075,6 +1076,13 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' }', " return { files, hash: hash.digest('hex') };", '};', + 'const inventory = (root, transform) => readTree(root, transform, undefined);', + 'const artifactInventory = async (root, transform) => {', + ' if (artifactManifest === undefined) return inventory(root, transform);', + " const selected = new Set(['agent-bundle.manifest.json', ...artifactManifest.files.map((file) => file.path)]);", + " for (const file of ['.env', '.env.local']) if (await exists(join(root, file))) selected.add(file);", + ' return readTree(root, transform, [...selected].sort(compareTreePaths));', + '};', '', 'const treeHash = async (root) => (await inventory(root)).hash;', '', @@ -1463,7 +1471,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' };', ' await cp(source, root, { errorOnExist: true, filter, force: false, recursive: true, verbatimSymlinks: true });', " if (expansion !== undefined) await writeFile(join(root, expansion.mcpDocument), expansion.expanded, 'utf8');", - ' const staged = await inventory(root);', + ' const staged = await artifactInventory(root);', " await writeFile(join(root, receiptFile), receiptFor(staged, receiptOptions), 'utf8');", ' return { inventory: staged, parent, root };', ' } catch (error) {', @@ -1525,7 +1533,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { '// commit lets --uninstall prove the repository is still the one staging wrote.', 'const writeMarketplaceReceipt = async (commit) => {', ' const previous = await readReceiptFile(marketplaceReceipt);', - ' const tree = await inventory(source);', + ' const tree = await artifactInventory(source);', ' if (previous !== undefined && previous.contentHash === tree.hash && previous.registrations[0]?.commit === commit) return;', ' await mkdir(receiptsRoot, { recursive: true });', ' // The committed repository is removed wholesale after a HEAD check; the receipt owns no individual files.', @@ -1588,6 +1596,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' if (nestedGit !== undefined) {', ' throw new Error(`--mode marketplace refuses bundle-internal Git metadata at ${JSON.stringify(nestedGit)}: git would record it as an empty gitlink and Cursor would import a plugin without files. Stage from a built bundle directory without .git, or use the default local mode.`);', ' }', + ' await artifactInventory(source);', ' await mkdir(marketplaceRoot, { recursive: true });', ' if (await exists(marketplaceRepo)) {', ' let stagedVersion;', @@ -1596,6 +1605,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' if (stagedVersion !== undefined && stagedVersion !== pluginVersion) {', ' throw new Error(`Refusing version collision at ${marketplaceRepo}: found ${stagedVersion}, requested ${pluginVersion}.`);', ' }', + " // The staged plugin is an unfiltered copy of the bundle, so both sides hash the files present.", " if (await exists(marketplacePlugin) && await exists(join(marketplaceRepo, '.git')) && await treeHash(source) === await treeHash(marketplacePlugin)) {", ' let stagedManifest;', " try { stagedManifest = await readFile(marketplaceManifestPath, 'utf8'); }", @@ -1650,7 +1660,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { '}', '', '// The artifact is inventoried (and any unsupported entry refused) before anything is created in the home.', - 'const artifact = await inventory(source, cursorTransform);', + 'const artifact = await artifactInventory(source, cursorTransform);', '// The receipt records which host directories this run creates on the way to the plugin root (a fresh Cursor', '// home has no plugins/local), so --uninstall can prune exactly those and no more.', 'const createdHostDirectories = [];', diff --git a/packages/agent-bundle/tests/install-surface.test.ts b/packages/agent-bundle/tests/install-surface.test.ts index d7bb21a0b..3a345956b 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -1076,6 +1076,139 @@ it('emitted install.mjs mirrors the core replace policy: no-op, owned-only repla } }, 60_000); +it('emitted install.mjs refuses a foreign destination that lacks artifact-manifest paths', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-foreign-manifest-')); + 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'); + const foreignReceipt = join(destination, '.plugin-library-install.json'); + try { + const writes = writesFor('cursor'); + await mkdir(join(bundle, '.cursor-plugin'), { recursive: true }); + await mkdir(join(destination, 'skills'), { 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, 'agent-bundle.compile-evidence.json'), '{}\n'), + writeFile(join(bundle, 'agent-bundle.manifest.json'), `${JSON.stringify({ + files: [{ path: 'agent-bundle.compile-evidence.json' }, { path: 'payload.txt' }], + projections: [{ builtInHost: 'cursor', documents: { plugin: '.cursor-plugin/plugin.json' } }], + })}\n`), + writeFile(foreignReceipt, '{ "installer": "plugin-library" }\n'), + writeFile(join(destination, 'skills', 'SKILL.md'), '# kept\n'), + ]); + + const refused = await run(installer, [], home); + expect(refused.code).toBe(1); + expect(refused.stderr).toContain('Refusing foreign install'); + expect(refused.stderr).not.toContain('ENOENT'); + expect(await readFile(foreignReceipt, 'utf8')).toBe('{ "installer": "plugin-library" }\n'); + expect(await readFile(join(destination, 'skills', 'SKILL.md'), 'utf8')).toBe('# kept\n'); + + const replaced = await run(installer, ['--replace'], home); + expect(replaced.code).toBe(1); + expect(replaced.stderr).toContain('Refusing foreign install'); + expect(replaced.stderr).toContain('--replace does not apply'); + expect(await readFile(join(destination, 'skills', 'SKILL.md'), 'utf8')).toBe('# kept\n'); + + await rm(join(bundle, 'agent-bundle.compile-evidence.json')); + const broken = await run(installer, [], home); + expect(broken.code).toBe(1); + expect(broken.stderr).toContain('bundle does not match its manifest: agent-bundle.compile-evidence.json is missing.'); + expect(broken.stderr).not.toContain('lstat'); + expect(await readFile(foreignReceipt, 'utf8')).toBe('{ "installer": "plugin-library" }\n'); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +it('emitted install.mjs reruns a marketplace stage with unlisted files as already staged and refuses a newly declared path', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-marketplace-restage-')); + const bundle = join(root, 'bundle'); + const home = join(root, 'home'); + const installer = join(bundle, 'install.mjs'); + const stagedPlugin = join(home, '.cursor', 'agent-bundle', 'marketplaces', 'install-fixture', 'plugins', 'install-fixture'); + const manifest = (extra: readonly string[]) => `${JSON.stringify({ + files: [{ path: 'payload.txt' }, ...extra.map((path) => ({ path }))], + projections: [{ builtInHost: 'cursor', documents: { plugin: '.cursor-plugin/plugin.json' } }], + })}\n`; + 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, 'package.json'), '{ "name": "install-fixture" }\n'), + writeFile(join(bundle, 'agent-bundle.manifest.json'), manifest([])), + ]); + + const staged = await run(installer, ['--mode', 'marketplace'], home); + expect(staged).toMatchObject({ code: 0, stderr: '' }); + expect(staged.stdout).toContain('Staged install-fixture@1.2.3'); + expect(await readFile(join(stagedPlugin, 'payload.txt'), 'utf8')).toBe('payload\n'); + const commit = /@ ([0-9a-f]{40})/u.exec(staged.stdout)?.[1]; + expect(commit).toMatch(/^[0-9a-f]{40}$/u); + + const rerun = await run(installer, ['--mode', 'marketplace'], home); + expect(rerun).toMatchObject({ code: 0, stderr: '' }); + expect(rerun.stdout).toContain('Already staged install-fixture@1.2.3'); + expect(rerun.stdout).toContain(`@ ${commit}`); + + await writeFile(join(bundle, 'extra.txt'), 'extra\n'); + await writeFile(join(bundle, 'agent-bundle.manifest.json'), manifest(['extra.txt'])); + const restaged = await run(installer, ['--mode', 'marketplace'], home); + expect(restaged.code).toBe(1); + expect(restaged.stderr).toContain('Refusing content collision'); + expect(restaged.stderr).not.toContain('ENOENT'); + expect(await readFile(join(stagedPlugin, 'payload.txt'), 'utf8')).toBe('payload\n'); + await expect(readFile(join(stagedPlugin, 'extra.txt'))).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +it('emitted install.mjs --uninstall --force removes present files from a pre-receipt copy and keeps state/', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-legacy-uninstall-')); + 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(destination, '.cursor-plugin'), { recursive: true }); + await mkdir(join(destination, 'state'), { 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, 'agent-bundle.manifest.json'), `${JSON.stringify({ + files: [{ path: 'payload.txt' }], + projections: [{ builtInHost: 'cursor', documents: { plugin: '.cursor-plugin/plugin.json' } }], + })}\n`), + writeFile(join(destination, 'INSTALL.md'), 'legacy\n'), + writeFile(join(destination, 'install.mjs'), 'legacy\n'), + writeFile(join(destination, '.cursor-plugin', 'plugin.json'), JSON.stringify({ name: 'install-fixture', version: '1.2.3' })), + writeFile(join(destination, 'operator.txt'), 'operator\n'), + writeFile(join(destination, 'state', 'plugin.sqlite'), 'durable\n'), + ]); + + const removed = await run(installer, ['--uninstall', '--force'], home); + expect(removed).toMatchObject({ code: 0, stderr: '' }); + await expect(readFile(join(destination, 'operator.txt'))).rejects.toMatchObject({ code: 'ENOENT' }); + expect(await readFile(join(destination, 'state', 'plugin.sqlite'), 'utf8')).toBe('durable\n'); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + it('emitted install.mjs marks new explicit state roots and retains pre-existing ones', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-state-ownership-mjs-')); const bundle = join(root, 'bundle'); diff --git a/website/docs/en/guide/distribution/installation.mdx b/website/docs/en/guide/distribution/installation.mdx index 0c4b2d8a1..f583b295b 100644 --- a/website/docs/en/guide/distribution/installation.mdx +++ b/website/docs/en/guide/distribution/installation.mdx @@ -154,7 +154,13 @@ shares one replace policy. An identical copy is an `already-installed` no-op. A copy of the **same version whose content hash differs** is replaced automatically, so rebuilding without a version bump no longer needs an uninstall and `rm -rf`. A different version is refused with `AB7005` unless you pass `--replace` (alias `--force`), and a foreign directory — one -this plugin's installer did not place — is refused either way. Cursor and Amp copies carry an install receipt +this plugin's installer did not place — is refused either way. The standalone `install.mjs` hashes a +receipt-less destination from the files present there. That includes a legacy copy and a staged +marketplace plugin. When `agent-bundle.manifest.json` is present, the artifact hash uses that +manifest's `files[]`, and a listed path missing from the artifact fails the run. A listed path +missing from a foreign or staged destination is omitted from its hash, so the hashes differ and +install refuses that copy instead of throwing `ENOENT`. Owned same-version copies can be repaired. +Cursor and Amp copies carry an install receipt (`.agent-bundle-install.json`: plugin, version, host, content hash, owned files); replacement is in place and touches owned files only, never unowned entries such as legacy or in-place `state/`, and `--replace` adopts a pre-receipt copy. Current artifact builds keep framework state under diff --git a/website/docs/zh/guide/distribution/installation.mdx b/website/docs/zh/guide/distribution/installation.mdx index f7b73286e..69b29dede 100644 --- a/website/docs/zh/guide/distribution/installation.mdx +++ b/website/docs/zh/guide/distribution/installation.mdx @@ -126,7 +126,9 @@ Amp 不属于开发期安装宿主;请使用其有归属回执的 `agent-bundl 每个输出的安装器——`agent-bundle install ` 与独立的 `install.mjs`——共用同一套替换策略。 内容完全相同的副本是 `already-installed` 空操作。**版本相同但内容哈希不同**的副本会被自动替换,因此不升版本 地重建不再需要卸载加 `rm -rf`。版本不同则以 `AB7005` 拒绝,除非传入 `--replace`(别名 `--force`);外来目录 -——不是本插件安装器放置的——无论如何都会被拒绝。Cursor 与 Amp 副本携带安装回执(`.agent-bundle-install.json`: +——不是本插件安装器放置的——无论如何都会被拒绝。独立的 `install.mjs` 对没有回执的目标目录按其中实际存在的文件计算哈希, +包括回执出现之前的副本和已暂存的 marketplace 插件。存在 `agent-bundle.manifest.json` 时,产物哈希使用该清单的 `files[]`, +清单列出而产物缺失的路径会使安装失败。外来或已暂存的目标目录中缺少的清单路径不进入目标哈希,因此两边哈希不同,安装拒绝该副本,而不是抛出 `ENOENT`。属于本安装器的同版本副本可以修复。Cursor 与 Amp 副本携带安装回执(`.agent-bundle-install.json`: 插件、版本、宿主、内容哈希、归属文件);替换就地进行,只触碰归属文件,绝不动旧版或就地的 `state/` 之类的非归属条目, `--replace` 会接管回执出现之前的副本。本发行版构建的产物把框架状态放在 `~/.agent-bundle/state/-`(`AGENT_BUNDLE_STATE_ROOT` 覆盖该位置)。`uninstall`