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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/foreign-destination-inventory.md
Original file line number Diff line number Diff line change
@@ -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)
26 changes: 18 additions & 8 deletions packages/agent-bundle/src/install/surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');",
Expand All @@ -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))) {',
Expand All @@ -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;',
'',
Expand Down Expand Up @@ -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) {',
Expand Down Expand Up @@ -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.',
Expand Down Expand Up @@ -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;',
Expand All @@ -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'); }",
Expand Down Expand Up @@ -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 = [];',
Expand Down
133 changes: 133 additions & 0 deletions packages/agent-bundle/tests/install-surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
8 changes: 7 additions & 1 deletion website/docs/en/guide/distribution/installation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion website/docs/zh/guide/distribution/installation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,9 @@ Amp 不属于开发期安装宿主;请使用其有归属回执的 `agent-bundl
每个输出的安装器——`agent-bundle install <host>` 与独立的 `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/<plugin>-<digest>`(`AGENT_BUNDLE_STATE_ROOT` 覆盖该位置)。`uninstall`
Expand Down
Loading