Skip to content

Commit 9edd7ef

Browse files
fix(build): compare routed CLI bin collisions case-insensitively (AB4766)
Codex review on #419: a host-emitted `bin/MyPlugin.mjs` is the same file as the generated `bin/myplugin.mjs` on macOS and Windows, so the collision check folds case and names both paths in the diagnostic.
1 parent ecce21c commit 9edd7ef

3 files changed

Lines changed: 29 additions & 8 deletions

File tree

‎docs/diagnostics.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -402,7 +402,7 @@ that claims the same path. See “The routed CLI shell” in
402402
| Code | Severity | Trigger |
403403
| --- | --- | --- |
404404
| `AB4765` | warning | The project has a routed CLI but a selected target's adapter publishes no supported `cli` capability, so that artifact ships no `bin/<name>.mjs`. Skills, hooks, and scripts in that artifact cannot invoke the routed CLI. `inspect` lists the same omission as an `unsupported-capability` skip of the `cli` component. Publish the capability (with a `cliBin` artifact layout) on the adapter, or keep references to the bin out of that target's surfaces. |
405-
| `AB4766` | error (build) | A target plan already emits `bin/<name>.mjs` or `bin/<name>-flight.mjs` (for example a Claude `claude.bin` directory shipping a file of that name). The routed CLI owns those paths, so the build refuses instead of choosing. Rename or remove the host-emitted file, or set `bin: false` to keep it and drop the routed CLI executable. |
405+
| `AB4766` | error (build) | A target plan already emits `bin/<name>.mjs` or `bin/<name>-flight.mjs` (for example a Claude `claude.bin` directory shipping a file of that name), compared case-insensitively because those are one file on macOS and Windows. The routed CLI owns those paths, so the build refuses instead of choosing. Rename or remove the host-emitted file, or set `bin: false` to keep it and drop the routed CLI executable. |
406406

407407
## Config beside a route-generated MCP server (`AB4340`)
408408

‎packages/agent-bundle/src/build/cli-bins.ts‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -203,22 +203,34 @@ export const compileCliBins = async (
203203
* AB4766: the routed CLI bin's artifact paths are framework-owned. A target
204204
* plan that already places a file there (for example a Claude `claude.bin`
205205
* directory shipping `<plugin-name>.mjs`) cannot be merged silently, so the
206-
* build refuses with the colliding paths named.
206+
* build refuses with the colliding paths named. Paths are compared
207+
* case-folded: on the case-insensitive filesystems most plugins are developed
208+
* and installed on, `bin/MyPlugin.mjs` and `bin/myplugin.mjs` are one file,
209+
* and a name that differs only by case is a hazard everywhere else.
207210
*/
208211
export const cliBinCollisionDiagnostics = (
209212
model: NormalizedPlugin,
210213
target: string,
211214
entries: readonly TargetArtifactEntry[],
212215
): readonly Diagnostic[] => {
213-
const planned = new Set(entries.map((entry) => entry.relativePath));
216+
const planned = new Map<string, string>();
217+
for (const entry of entries) {
218+
const folded = entry.relativePath.toLowerCase();
219+
if (!planned.has(folded)) planned.set(folded, entry.relativePath);
220+
}
214221
return Object.freeze(routedCliBins(model).flatMap((bin) => {
215222
const rendered = generatedCli(bin).commands.some((command) => command.rendered);
216223
return [cliBinArtifactPath(bin.name), ...(rendered ? [cliBinWorkerArtifactPath(bin.name)] : [])]
217-
.filter((path) => planned.has(path))
218-
.map((path): Diagnostic => ({
224+
.flatMap((owned) => {
225+
const emitted = planned.get(owned.toLowerCase());
226+
return emitted === undefined ? [] : [{ emitted, owned }];
227+
})
228+
.map(({ emitted, owned }): Diagnostic => ({
219229
code: 'AB4766',
220-
message: `Target ${JSON.stringify(target)} already emits ${JSON.stringify(path)}, which the routed CLI bin ${JSON.stringify(bin.name)} owns; the compiler never chooses silently.`,
221-
recovery: `Rename or remove the host-emitted ${JSON.stringify(path)} (for example the file in the configured claude.bin directory), or set bin: false to keep the host file and drop the routed CLI executable.`,
230+
message: emitted === owned
231+
? `Target ${JSON.stringify(target)} already emits ${JSON.stringify(emitted)}, which the routed CLI bin ${JSON.stringify(bin.name)} owns; the compiler never chooses silently.`
232+
: `Target ${JSON.stringify(target)} already emits ${JSON.stringify(emitted)}, which differs only by case from ${JSON.stringify(owned)} owned by the routed CLI bin ${JSON.stringify(bin.name)}; on a case-insensitive filesystem they are one file, so the compiler never chooses silently.`,
233+
recovery: `Rename or remove the host-emitted ${JSON.stringify(emitted)} (for example the file in the configured claude.bin directory), or set bin: false to keep the host file and drop the routed CLI executable.`,
222234
severity: 'error',
223235
sourcePath: bin.provenance.sourcePath,
224236
target,

‎packages/agent-bundle/tests/artifact-cli-bin.test.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,11 @@ it('refuses a host-emitted file that collides with the routed CLI bin (AB4766)',
290290
// CLI owns: the compiler never chooses between them silently.
291291
await writeProjectFile(root, `host-bin/${pluginName}.mjs`, "console.log('host bin');\n");
292292
await chmod(join(root, 'host-bin', `${pluginName}.mjs`), 0o755);
293+
// A second entry differing only by case is the same file on macOS and
294+
// Windows, so it is a collision too and is named beside the owned path.
295+
const caseVariant = `${pluginName.toUpperCase()}-flight.mjs`;
296+
await writeProjectFile(root, `host-bin/${caseVariant}`, "console.log('host worker');\n");
297+
await chmod(join(root, 'host-bin', caseVariant), 0o755);
293298
await writeProjectFile(root, 'agent-bundle.config.ts', [
294299
"import { defineConfig } from 'agent-bundle/config';",
295300
'export default defineConfig({',
@@ -300,8 +305,12 @@ it('refuses a host-emitted file that collides with the routed CLI bin (AB4766)',
300305
'',
301306
].join('\n'));
302307

303-
await expect(build({ output: 'artifact', root })).rejects.toThrow(
308+
const failure = build({ output: 'artifact', root });
309+
await expect(failure).rejects.toThrow(
304310
new RegExp(`\\[AB4766\\] Target "claude" already emits "bin/${pluginName}\\.mjs"`, 'u'),
305311
);
312+
await expect(failure).rejects.toThrow(
313+
new RegExp(`\\[AB4766\\] Target "claude" already emits "bin/${caseVariant}", which differs only by case from "bin/${pluginName}-flight\\.mjs"`, 'u'),
314+
);
306315
await expect(stat(join(root, 'artifact'))).rejects.toMatchObject({ code: 'ENOENT' });
307316
});

0 commit comments

Comments
 (0)