Skip to content

Commit a21a1c8

Browse files
Merge pull request #510 from corbitsdev/cl-6716-prevent-later-discovery-origins-from-shadowing-repo
Preserve repo defaultEnabled through plugin dedupe shadowing
2 parents 0cb1571 + 68bda5e commit a21a1c8

3 files changed

Lines changed: 110 additions & 3 deletions

File tree

‎src/plugins/loader.test.ts‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { describe, test, expect } from "bun:test";
2+
import { dedupePluginModules, type PluginModule } from "./loader.js";
3+
import { isPluginModuleEnabled } from "./register.js";
4+
5+
function repoDefaultEnabled(id: string): PluginModule {
6+
return {
7+
manifest: { id, name: id, kind: "agent", defaultEnabled: true },
8+
origin: "repo",
9+
};
10+
}
11+
12+
function userInstall(id: string): PluginModule {
13+
return {
14+
manifest: { id, name: id, kind: "agent" },
15+
origin: "user",
16+
source: "claude",
17+
};
18+
}
19+
20+
describe("dedupePluginModules", () => {
21+
test("last occurrence wins for content", () => {
22+
const repo = repoDefaultEnabled("scout");
23+
const user = userInstall("scout");
24+
const [result] = dedupePluginModules([repo, user]);
25+
expect(result).toMatchObject({ origin: "user", source: "claude" });
26+
});
27+
28+
// CL-6716: a later non-repo install with the same id as a repo
29+
// defaultEnabled plugin must not silently turn the bundled default off.
30+
test("stamps shadowedRepoDefaultEnabled when a non-repo module shadows a repo defaultEnabled id", () => {
31+
const repo = repoDefaultEnabled("scout");
32+
const user = userInstall("scout");
33+
const [result] = dedupePluginModules([repo, user]);
34+
expect(result!.shadowedRepoDefaultEnabled).toBe(true);
35+
});
36+
37+
test("does not stamp shadowedRepoDefaultEnabled when the repo module wasn't defaultEnabled", () => {
38+
const repo: PluginModule = { manifest: { id: "scout", name: "scout", kind: "agent" }, origin: "repo" };
39+
const user = userInstall("scout");
40+
const [result] = dedupePluginModules([repo, user]);
41+
expect(result!.shadowedRepoDefaultEnabled).toBeUndefined();
42+
});
43+
44+
test("does not stamp unrelated ids", () => {
45+
const repo = repoDefaultEnabled("scout");
46+
const other = userInstall("other");
47+
const result = dedupePluginModules([repo, other]);
48+
expect(result.find((m) => m.manifest?.id === "other")!.shadowedRepoDefaultEnabled).toBeUndefined();
49+
});
50+
51+
test("propagates the shadow stamp through a chain of later installs", () => {
52+
const repo = repoDefaultEnabled("scout");
53+
const user = userInstall("scout");
54+
const path: PluginModule = { manifest: { id: "scout", name: "scout", kind: "agent" }, origin: "path" };
55+
const [result] = dedupePluginModules([repo, user, path]);
56+
expect(result).toMatchObject({ origin: "path" });
57+
expect(result!.shadowedRepoDefaultEnabled).toBe(true);
58+
});
59+
});
60+
61+
describe("isPluginModuleEnabled with dedupe shadowing", () => {
62+
test("a same-id later install stays enabled by default after shadowing a repo defaultEnabled plugin", () => {
63+
const repo = repoDefaultEnabled("scout");
64+
const user = userInstall("scout");
65+
const [survivor] = dedupePluginModules([repo, user]);
66+
expect(isPluginModuleEnabled(survivor!, {})).toBe(true);
67+
});
68+
69+
test("an explicit disable in settings still wins over the preserved default-on", () => {
70+
const repo = repoDefaultEnabled("scout");
71+
const user = userInstall("scout");
72+
const [survivor] = dedupePluginModules([repo, user]);
73+
expect(isPluginModuleEnabled(survivor!, { scout: { enabled: false } })).toBe(false);
74+
});
75+
76+
test("without dedupe shadowing, a plain user-origin module needs an explicit enable", () => {
77+
const user = userInstall("scout");
78+
expect(isPluginModuleEnabled(user, {})).toBe(false);
79+
});
80+
});

‎src/plugins/loader.ts‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,14 @@ export type PluginModule = {
5858
* `origin`, which drives trust gating.
5959
*/
6060
source?: string;
61+
/**
62+
* Set by dedupePluginModules when this module's id shadowed an earlier
63+
* repo module that had manifest.defaultEnabled === true. Lets
64+
* isPluginModuleEnabled keep the id default-on after a same-id later
65+
* install replaces the bundled module, without requiring an explicit
66+
* settings flag (CL-6716).
67+
*/
68+
shadowedRepoDefaultEnabled?: boolean;
6169
};
6270

6371
// Read and validate a manifest.json beside the module. Plugins may declare
@@ -563,6 +571,13 @@ export async function discoverUserPlugins(
563571
// paths), so "last wins" means an explicit path overrides a user plugin, which
564572
// overrides a bundled one. Modules without a manifest carry no id and are kept
565573
// as-is.
574+
//
575+
// A later non-repo module with the same id as a repo defaultEnabled plugin
576+
// would otherwise silently turn the bundled default off — the survivor is
577+
// non-repo, so isPluginModuleEnabled's origin==="repo" check fails and
578+
// enablement then requires an explicit settings flag (CL-6716). Carry the
579+
// repo default-on forward via shadowedRepoDefaultEnabled so the id stays
580+
// enabled by default unless the user explicitly disables it in settings.
566581
export function dedupePluginModules(modules: PluginModule[]): PluginModule[] {
567582
const indexById = new Map<string, number>();
568583
const result: PluginModule[] = [];
@@ -574,7 +589,13 @@ export function dedupePluginModules(modules: PluginModule[]): PluginModule[] {
574589
}
575590
const existing = indexById.get(id);
576591
if (existing !== undefined) {
577-
result[existing] = mod;
592+
const prev = result[existing]!;
593+
const wasRepoDefaultEnabled =
594+
prev.shadowedRepoDefaultEnabled === true
595+
|| (prev.origin === "repo" && prev.manifest?.defaultEnabled === true);
596+
result[existing] = wasRepoDefaultEnabled
597+
? { ...mod, shadowedRepoDefaultEnabled: true }
598+
: mod;
578599
} else {
579600
indexById.set(id, result.length);
580601
result.push(mod);

‎src/plugins/register.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ export function isPluginEnabled(config: Record<string, PluginConfig>, id: string
99

1010
// Enablement for a loaded module: explicit settings win; otherwise only a
1111
// first-party repo plugin with manifest.defaultEnabled turns on. Marketplace
12-
// (user), path, and project plugins cannot self-enable via the flag.
12+
// (user), path, and project plugins cannot self-enable via the flag — except
13+
// when dedupePluginModules stamped shadowedRepoDefaultEnabled, meaning this
14+
// module's id shadowed a repo defaultEnabled plugin during discovery dedupe;
15+
// the bundled default-on survives the shadowing (CL-6716).
1316
export function isPluginModuleEnabled(
1417
mod: PluginModule,
1518
config: Record<string, PluginConfig | undefined>,
@@ -19,7 +22,10 @@ export function isPluginModuleEnabled(
1922
const enabled = config[id]?.enabled;
2023
if (enabled === true) return true;
2124
if (enabled === false) return false;
22-
return mod.origin === "repo" && mod.manifest?.defaultEnabled === true;
25+
return (
26+
(mod.origin === "repo" && mod.manifest?.defaultEnabled === true)
27+
|| mod.shadowedRepoDefaultEnabled === true
28+
);
2329
}
2430

2531
// Mark a plugin enabled while preserving credentials/consented and other fields.

0 commit comments

Comments
 (0)