Skip to content

Commit a0652c1

Browse files
fix(install): hash install.mjs destinations from files present (#818)
* fix(install): hash install.mjs destinations from files present A destination walk used the artifact manifest file list. A missing declared path threw ENOENT before the foreign-install refusal. * fix(install): hash both sides of the marketplace rerun check from files present The staged plugin is an unfiltered copy of the bundle. A manifest-selected source hash never matched a root with unlisted files, so an identical rerun refused instead of reporting already staged. * docs: clarify which incomplete installer destinations are refused
1 parent 752d29a commit a0652c1

5 files changed

Lines changed: 166 additions & 10 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"agent-bundle": patch
3+
---
4+
5+
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)

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

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1036,7 +1036,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => {
10361036
'// Deterministic tree walk: symlinks and special files refused, the root receipt skipped. `transform`',
10371037
'// maps a file\'s bytes to what the Cursor copy will hold (the Agent Plugins mcp.json expansion below), so',
10381038
'// the artifact hash describes the installed form and reruns compare like for like.',
1039-
'const inventory = async (root, transform) => {',
1039+
'const readTree = async (root, transform, selectedPaths) => {',
10401040
' const rootMetadata = await lstat(root);',
10411041
" if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) throw unsupported('.');",
10421042
" const hash = createHash('sha256');",
@@ -1057,10 +1057,11 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => {
10571057
' const bytes = await readFile(absolute);',
10581058
' hashEntry(hash, relative, metadata, transform === undefined ? bytes : transform(posixPath, bytes));',
10591059
' };',
1060-
' if (artifactManifest !== undefined) {',
1061-
" const selected = new Set(['agent-bundle.manifest.json', ...artifactManifest.files.map((file) => file.path)]);",
1062-
" for (const file of ['.env', '.env.local']) if (await exists(join(root, file))) selected.add(file);",
1063-
' for (const file of [...selected].sort(compareTreePaths)) await visit(file);',
1060+
' if (selectedPaths !== undefined) {',
1061+
' for (const file of selectedPaths) {',
1062+
' if (!(await exists(join(root, file)))) throw new Error(`bundle does not match its manifest: ${file} is missing.`);',
1063+
' await visit(file);',
1064+
' }',
10641065
" return { files, hash: hash.digest('hex') };",
10651066
' }',
10661067
' for (const entry of (await readdir(root)).sort((left, right) => left.localeCompare(right))) {',
@@ -1075,6 +1076,13 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => {
10751076
' }',
10761077
" return { files, hash: hash.digest('hex') };",
10771078
'};',
1079+
'const inventory = (root, transform) => readTree(root, transform, undefined);',
1080+
'const artifactInventory = async (root, transform) => {',
1081+
' if (artifactManifest === undefined) return inventory(root, transform);',
1082+
" const selected = new Set(['agent-bundle.manifest.json', ...artifactManifest.files.map((file) => file.path)]);",
1083+
" for (const file of ['.env', '.env.local']) if (await exists(join(root, file))) selected.add(file);",
1084+
' return readTree(root, transform, [...selected].sort(compareTreePaths));',
1085+
'};',
10781086
'',
10791087
'const treeHash = async (root) => (await inventory(root)).hash;',
10801088
'',
@@ -1463,7 +1471,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => {
14631471
' };',
14641472
' await cp(source, root, { errorOnExist: true, filter, force: false, recursive: true, verbatimSymlinks: true });',
14651473
" if (expansion !== undefined) await writeFile(join(root, expansion.mcpDocument), expansion.expanded, 'utf8');",
1466-
' const staged = await inventory(root);',
1474+
' const staged = await artifactInventory(root);',
14671475
" await writeFile(join(root, receiptFile), receiptFor(staged, receiptOptions), 'utf8');",
14681476
' return { inventory: staged, parent, root };',
14691477
' } catch (error) {',
@@ -1525,7 +1533,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => {
15251533
'// commit lets --uninstall prove the repository is still the one staging wrote.',
15261534
'const writeMarketplaceReceipt = async (commit) => {',
15271535
' const previous = await readReceiptFile(marketplaceReceipt);',
1528-
' const tree = await inventory(source);',
1536+
' const tree = await artifactInventory(source);',
15291537
' if (previous !== undefined && previous.contentHash === tree.hash && previous.registrations[0]?.commit === commit) return;',
15301538
' await mkdir(receiptsRoot, { recursive: true });',
15311539
' // 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 => {
15881596
' if (nestedGit !== undefined) {',
15891597
' 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.`);',
15901598
' }',
1599+
' await artifactInventory(source);',
15911600
' await mkdir(marketplaceRoot, { recursive: true });',
15921601
' if (await exists(marketplaceRepo)) {',
15931602
' let stagedVersion;',
@@ -1596,6 +1605,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => {
15961605
' if (stagedVersion !== undefined && stagedVersion !== pluginVersion) {',
15971606
' throw new Error(`Refusing version collision at ${marketplaceRepo}: found ${stagedVersion}, requested ${pluginVersion}.`);',
15981607
' }',
1608+
" // The staged plugin is an unfiltered copy of the bundle, so both sides hash the files present.",
15991609
" if (await exists(marketplacePlugin) && await exists(join(marketplaceRepo, '.git')) && await treeHash(source) === await treeHash(marketplacePlugin)) {",
16001610
' let stagedManifest;',
16011611
" try { stagedManifest = await readFile(marketplaceManifestPath, 'utf8'); }",
@@ -1650,7 +1660,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => {
16501660
'}',
16511661
'',
16521662
'// The artifact is inventoried (and any unsupported entry refused) before anything is created in the home.',
1653-
'const artifact = await inventory(source, cursorTransform);',
1663+
'const artifact = await artifactInventory(source, cursorTransform);',
16541664
'// The receipt records which host directories this run creates on the way to the plugin root (a fresh Cursor',
16551665
'// home has no plugins/local), so --uninstall can prune exactly those and no more.',
16561666
'const createdHostDirectories = [];',

‎packages/agent-bundle/tests/install-surface.test.ts‎

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1076,6 +1076,139 @@ it('emitted install.mjs mirrors the core replace policy: no-op, owned-only repla
10761076
}
10771077
}, 60_000);
10781078

1079+
it('emitted install.mjs refuses a foreign destination that lacks artifact-manifest paths', async () => {
1080+
const root = await mkdtemp(join(tmpdir(), 'agent-bundle-foreign-manifest-'));
1081+
const bundle = join(root, 'bundle');
1082+
const home = join(root, 'home');
1083+
const destination = join(home, '.cursor', 'plugins', 'local', 'install-fixture');
1084+
const installer = join(bundle, 'install.mjs');
1085+
const foreignReceipt = join(destination, '.plugin-library-install.json');
1086+
try {
1087+
const writes = writesFor('cursor');
1088+
await mkdir(join(bundle, '.cursor-plugin'), { recursive: true });
1089+
await mkdir(join(destination, 'skills'), { recursive: true });
1090+
await Promise.all([
1091+
writeFile(installer, writes.get('install.mjs') ?? ''),
1092+
writeFile(join(bundle, 'INSTALL.md'), writes.get('INSTALL.md') ?? ''),
1093+
writeFile(join(bundle, '.cursor-plugin', 'plugin.json'), JSON.stringify({ name: 'install-fixture', version: '1.2.3' })),
1094+
writeFile(join(bundle, 'payload.txt'), 'payload\n'),
1095+
writeFile(join(bundle, 'agent-bundle.compile-evidence.json'), '{}\n'),
1096+
writeFile(join(bundle, 'agent-bundle.manifest.json'), `${JSON.stringify({
1097+
files: [{ path: 'agent-bundle.compile-evidence.json' }, { path: 'payload.txt' }],
1098+
projections: [{ builtInHost: 'cursor', documents: { plugin: '.cursor-plugin/plugin.json' } }],
1099+
})}\n`),
1100+
writeFile(foreignReceipt, '{ "installer": "plugin-library" }\n'),
1101+
writeFile(join(destination, 'skills', 'SKILL.md'), '# kept\n'),
1102+
]);
1103+
1104+
const refused = await run(installer, [], home);
1105+
expect(refused.code).toBe(1);
1106+
expect(refused.stderr).toContain('Refusing foreign install');
1107+
expect(refused.stderr).not.toContain('ENOENT');
1108+
expect(await readFile(foreignReceipt, 'utf8')).toBe('{ "installer": "plugin-library" }\n');
1109+
expect(await readFile(join(destination, 'skills', 'SKILL.md'), 'utf8')).toBe('# kept\n');
1110+
1111+
const replaced = await run(installer, ['--replace'], home);
1112+
expect(replaced.code).toBe(1);
1113+
expect(replaced.stderr).toContain('Refusing foreign install');
1114+
expect(replaced.stderr).toContain('--replace does not apply');
1115+
expect(await readFile(join(destination, 'skills', 'SKILL.md'), 'utf8')).toBe('# kept\n');
1116+
1117+
await rm(join(bundle, 'agent-bundle.compile-evidence.json'));
1118+
const broken = await run(installer, [], home);
1119+
expect(broken.code).toBe(1);
1120+
expect(broken.stderr).toContain('bundle does not match its manifest: agent-bundle.compile-evidence.json is missing.');
1121+
expect(broken.stderr).not.toContain('lstat');
1122+
expect(await readFile(foreignReceipt, 'utf8')).toBe('{ "installer": "plugin-library" }\n');
1123+
} finally {
1124+
await rm(root, { force: true, recursive: true });
1125+
}
1126+
});
1127+
1128+
it('emitted install.mjs reruns a marketplace stage with unlisted files as already staged and refuses a newly declared path', async () => {
1129+
const root = await mkdtemp(join(tmpdir(), 'agent-bundle-marketplace-restage-'));
1130+
const bundle = join(root, 'bundle');
1131+
const home = join(root, 'home');
1132+
const installer = join(bundle, 'install.mjs');
1133+
const stagedPlugin = join(home, '.cursor', 'agent-bundle', 'marketplaces', 'install-fixture', 'plugins', 'install-fixture');
1134+
const manifest = (extra: readonly string[]) => `${JSON.stringify({
1135+
files: [{ path: 'payload.txt' }, ...extra.map((path) => ({ path }))],
1136+
projections: [{ builtInHost: 'cursor', documents: { plugin: '.cursor-plugin/plugin.json' } }],
1137+
})}\n`;
1138+
try {
1139+
const writes = writesFor('cursor');
1140+
await mkdir(join(bundle, '.cursor-plugin'), { recursive: true });
1141+
await mkdir(join(home, '.cursor'), { recursive: true });
1142+
await Promise.all([
1143+
writeFile(installer, writes.get('install.mjs') ?? ''),
1144+
writeFile(join(bundle, 'INSTALL.md'), writes.get('INSTALL.md') ?? ''),
1145+
writeFile(join(bundle, '.cursor-plugin', 'plugin.json'), JSON.stringify({ name: 'install-fixture', version: '1.2.3' })),
1146+
writeFile(join(bundle, 'payload.txt'), 'payload\n'),
1147+
writeFile(join(bundle, 'package.json'), '{ "name": "install-fixture" }\n'),
1148+
writeFile(join(bundle, 'agent-bundle.manifest.json'), manifest([])),
1149+
]);
1150+
1151+
const staged = await run(installer, ['--mode', 'marketplace'], home);
1152+
expect(staged).toMatchObject({ code: 0, stderr: '' });
1153+
expect(staged.stdout).toContain('Staged install-fixture@1.2.3');
1154+
expect(await readFile(join(stagedPlugin, 'payload.txt'), 'utf8')).toBe('payload\n');
1155+
const commit = /@ ([0-9a-f]{40})/u.exec(staged.stdout)?.[1];
1156+
expect(commit).toMatch(/^[0-9a-f]{40}$/u);
1157+
1158+
const rerun = await run(installer, ['--mode', 'marketplace'], home);
1159+
expect(rerun).toMatchObject({ code: 0, stderr: '' });
1160+
expect(rerun.stdout).toContain('Already staged install-fixture@1.2.3');
1161+
expect(rerun.stdout).toContain(`@ ${commit}`);
1162+
1163+
await writeFile(join(bundle, 'extra.txt'), 'extra\n');
1164+
await writeFile(join(bundle, 'agent-bundle.manifest.json'), manifest(['extra.txt']));
1165+
const restaged = await run(installer, ['--mode', 'marketplace'], home);
1166+
expect(restaged.code).toBe(1);
1167+
expect(restaged.stderr).toContain('Refusing content collision');
1168+
expect(restaged.stderr).not.toContain('ENOENT');
1169+
expect(await readFile(join(stagedPlugin, 'payload.txt'), 'utf8')).toBe('payload\n');
1170+
await expect(readFile(join(stagedPlugin, 'extra.txt'))).rejects.toMatchObject({ code: 'ENOENT' });
1171+
} finally {
1172+
await rm(root, { force: true, recursive: true });
1173+
}
1174+
});
1175+
1176+
it('emitted install.mjs --uninstall --force removes present files from a pre-receipt copy and keeps state/', async () => {
1177+
const root = await mkdtemp(join(tmpdir(), 'agent-bundle-legacy-uninstall-'));
1178+
const bundle = join(root, 'bundle');
1179+
const home = join(root, 'home');
1180+
const destination = join(home, '.cursor', 'plugins', 'local', 'install-fixture');
1181+
const installer = join(bundle, 'install.mjs');
1182+
try {
1183+
const writes = writesFor('cursor');
1184+
await mkdir(join(bundle, '.cursor-plugin'), { recursive: true });
1185+
await mkdir(join(destination, '.cursor-plugin'), { recursive: true });
1186+
await mkdir(join(destination, 'state'), { recursive: true });
1187+
await Promise.all([
1188+
writeFile(installer, writes.get('install.mjs') ?? ''),
1189+
writeFile(join(bundle, 'INSTALL.md'), writes.get('INSTALL.md') ?? ''),
1190+
writeFile(join(bundle, '.cursor-plugin', 'plugin.json'), JSON.stringify({ name: 'install-fixture', version: '1.2.3' })),
1191+
writeFile(join(bundle, 'payload.txt'), 'payload\n'),
1192+
writeFile(join(bundle, 'agent-bundle.manifest.json'), `${JSON.stringify({
1193+
files: [{ path: 'payload.txt' }],
1194+
projections: [{ builtInHost: 'cursor', documents: { plugin: '.cursor-plugin/plugin.json' } }],
1195+
})}\n`),
1196+
writeFile(join(destination, 'INSTALL.md'), 'legacy\n'),
1197+
writeFile(join(destination, 'install.mjs'), 'legacy\n'),
1198+
writeFile(join(destination, '.cursor-plugin', 'plugin.json'), JSON.stringify({ name: 'install-fixture', version: '1.2.3' })),
1199+
writeFile(join(destination, 'operator.txt'), 'operator\n'),
1200+
writeFile(join(destination, 'state', 'plugin.sqlite'), 'durable\n'),
1201+
]);
1202+
1203+
const removed = await run(installer, ['--uninstall', '--force'], home);
1204+
expect(removed).toMatchObject({ code: 0, stderr: '' });
1205+
await expect(readFile(join(destination, 'operator.txt'))).rejects.toMatchObject({ code: 'ENOENT' });
1206+
expect(await readFile(join(destination, 'state', 'plugin.sqlite'), 'utf8')).toBe('durable\n');
1207+
} finally {
1208+
await rm(root, { force: true, recursive: true });
1209+
}
1210+
});
1211+
10791212
it('emitted install.mjs marks new explicit state roots and retains pre-existing ones', async () => {
10801213
const root = await mkdtemp(join(tmpdir(), 'agent-bundle-state-ownership-mjs-'));
10811214
const bundle = join(root, 'bundle');

‎website/docs/en/guide/distribution/installation.mdx‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,13 @@ shares one replace policy. An identical copy is an `already-installed`
154154
no-op. A copy of the **same version whose content hash differs** is replaced automatically, so
155155
rebuilding without a version bump no longer needs an uninstall and `rm -rf`. A different version is
156156
refused with `AB7005` unless you pass `--replace` (alias `--force`), and a foreign directory — one
157-
this plugin's installer did not place — is refused either way. Cursor and Amp copies carry an install receipt
157+
this plugin's installer did not place — is refused either way. The standalone `install.mjs` hashes a
158+
receipt-less destination from the files present there. That includes a legacy copy and a staged
159+
marketplace plugin. When `agent-bundle.manifest.json` is present, the artifact hash uses that
160+
manifest's `files[]`, and a listed path missing from the artifact fails the run. A listed path
161+
missing from a foreign or staged destination is omitted from its hash, so the hashes differ and
162+
install refuses that copy instead of throwing `ENOENT`. Owned same-version copies can be repaired.
163+
Cursor and Amp copies carry an install receipt
158164
(`.agent-bundle-install.json`: plugin, version, host, content hash, owned files); replacement is in
159165
place and touches owned files only, never unowned entries such as legacy or in-place `state/`, and
160166
`--replace` adopts a pre-receipt copy. Current artifact builds keep framework state under

‎website/docs/zh/guide/distribution/installation.mdx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,9 @@ Amp 不属于开发期安装宿主;请使用其有归属回执的 `agent-bundl
126126
每个输出的安装器——`agent-bundle install <host>` 与独立的 `install.mjs`——共用同一套替换策略。
127127
内容完全相同的副本是 `already-installed` 空操作。**版本相同但内容哈希不同**的副本会被自动替换,因此不升版本
128128
地重建不再需要卸载加 `rm -rf`。版本不同则以 `AB7005` 拒绝,除非传入 `--replace`(别名 `--force`);外来目录
129-
——不是本插件安装器放置的——无论如何都会被拒绝。Cursor 与 Amp 副本携带安装回执(`.agent-bundle-install.json`:
129+
——不是本插件安装器放置的——无论如何都会被拒绝。独立的 `install.mjs` 对没有回执的目标目录按其中实际存在的文件计算哈希,
130+
包括回执出现之前的副本和已暂存的 marketplace 插件。存在 `agent-bundle.manifest.json` 时,产物哈希使用该清单的 `files[]`,
131+
清单列出而产物缺失的路径会使安装失败。外来或已暂存的目标目录中缺少的清单路径不进入目标哈希,因此两边哈希不同,安装拒绝该副本,而不是抛出 `ENOENT`。属于本安装器的同版本副本可以修复。Cursor 与 Amp 副本携带安装回执(`.agent-bundle-install.json`:
130132
插件、版本、宿主、内容哈希、归属文件);替换就地进行,只触碰归属文件,绝不动旧版或就地的 `state/` 之类的非归属条目,
131133
`--replace` 会接管回执出现之前的副本。本发行版构建的产物把框架状态放在
132134
`~/.agent-bundle/state/<plugin>-<digest>`(`AGENT_BUNDLE_STATE_ROOT` 覆盖该位置)。`uninstall`

0 commit comments

Comments
 (0)