diff --git a/src/shared/bundled-notify-ownership.test.ts b/src/shared/bundled-notify-ownership.test.ts index 0690a86a1..0881e5802 100644 --- a/src/shared/bundled-notify-ownership.test.ts +++ b/src/shared/bundled-notify-ownership.test.ts @@ -83,6 +83,19 @@ describe("ensureBundledNotifyOwnership", () => { expect(readConfig(userConfigPath).plugin).toEqual(["oh-my-openagent", canonicalEntry]) }) + test("rewrites recognized npm-prefixed external notify with version to bundled owner", () => { + // given + const userConfigPath = join(userConfigDir, "opencode.json") + writeFileSync(userConfigPath, JSON.stringify({ plugin: ["npm:kdco/notify@1.2.3", "oh-my-openagent"] }, null, 2) + "\n") + + // when + const result = ensureBundledNotifyOwnership({ projectDirectory: projectDir, packageRoot }) + + // then + expect(result.changedUserConfig).toBe(true) + expect(readConfig(userConfigPath).plugin).toEqual(["oh-my-openagent", canonicalEntry]) + }) + test("rewrites recognized tuple notify in user config when tuple options are empty", () => { // given const userConfigPath = join(userConfigDir, "opencode.json") @@ -150,6 +163,19 @@ describe("ensureBundledNotifyOwnership", () => { expect(readConfig(userConfigPath).plugin).toEqual(["alias@npm:@custom/opencode-notify@1.2.3", "oh-my-openagent"]) }) + test("fails loudly for npm alias string spec targeting recognized kdco/notify package", () => { + // given + const userConfigPath = join(userConfigDir, "opencode.json") + writeFileSync(userConfigPath, JSON.stringify({ plugin: ["alias@npm:kdco/notify@1.2.3", "oh-my-openagent"] }, null, 2) + "\n") + + // when + const run = () => ensureBundledNotifyOwnership({ projectDirectory: projectDir, packageRoot }) + + // then + expect(run).toThrow("Unsafe external notify plugin ownership detected") + expect(readConfig(userConfigPath).plugin).toEqual(["alias@npm:kdco/notify@1.2.3", "oh-my-openagent"]) + }) + test("fails loudly for npm alias tuple spec targeting custom opencode-notify package", () => { // given const userConfigPath = join(userConfigDir, "opencode.json") @@ -166,6 +192,93 @@ describe("ensureBundledNotifyOwnership", () => { expect(readConfig(userConfigPath).plugin).toEqual([["alias@npm:@custom/opencode-notify@1.2.3", {}], "oh-my-openagent"]) }) + test("fails loudly for npm alias tuple spec targeting recognized kdco/notify package", () => { + // given + const userConfigPath = join(userConfigDir, "opencode.json") + writeFileSync( + userConfigPath, + JSON.stringify({ plugin: [["alias@npm:kdco/notify@1.2.3", {}], "oh-my-openagent"] }, null, 2) + "\n", + ) + + // when + const run = () => ensureBundledNotifyOwnership({ projectDirectory: projectDir, packageRoot }) + + // then + expect(run).toThrow("Unsafe external notify plugin ownership detected") + expect(readConfig(userConfigPath).plugin).toEqual([["alias@npm:kdco/notify@1.2.3", {}], "oh-my-openagent"]) + }) + + test("fails loudly for non-exact recognized-like spec with nested npm alias in string form", () => { + // given + const userConfigPath = join(userConfigDir, "opencode.json") + writeFileSync(userConfigPath, JSON.stringify({ plugin: ["kdco/notify@npm:kdco/notify@1.2.3", "oh-my-openagent"] }, null, 2) + "\n") + + // when + const run = () => ensureBundledNotifyOwnership({ projectDirectory: projectDir, packageRoot }) + + // then + expect(run).toThrow("Unsafe external notify plugin ownership detected") + expect(readConfig(userConfigPath).plugin).toEqual(["kdco/notify@npm:kdco/notify@1.2.3", "oh-my-openagent"]) + }) + + test("fails loudly for non-exact recognized-like spec with nested npm alias in tuple form", () => { + // given + const userConfigPath = join(userConfigDir, "opencode.json") + writeFileSync( + userConfigPath, + JSON.stringify({ plugin: [["kdco/notify@npm:kdco/notify@1.2.3", {}], "oh-my-openagent"] }, null, 2) + "\n", + ) + + // when + const run = () => ensureBundledNotifyOwnership({ projectDirectory: projectDir, packageRoot }) + + // then + expect(run).toThrow("Unsafe external notify plugin ownership detected") + expect(readConfig(userConfigPath).plugin).toEqual([["kdco/notify@npm:kdco/notify@1.2.3", {}], "oh-my-openagent"]) + }) + + test("fails loudly for recognized notify package with file source suffix in string form", () => { + // given + const userConfigPath = join(userConfigDir, "opencode.json") + writeFileSync(userConfigPath, JSON.stringify({ plugin: ["kdco/notify@file:../local", "oh-my-openagent"] }, null, 2) + "\n") + + // when + const run = () => ensureBundledNotifyOwnership({ projectDirectory: projectDir, packageRoot }) + + // then + expect(run).toThrow("Unsafe external notify plugin ownership detected") + expect(readConfig(userConfigPath).plugin).toEqual(["kdco/notify@file:../local", "oh-my-openagent"]) + }) + + test("fails loudly for recognized notify package with file source suffix in tuple form", () => { + // given + const userConfigPath = join(userConfigDir, "opencode.json") + writeFileSync( + userConfigPath, + JSON.stringify({ plugin: [["kdco/notify@file:../local", {}], "oh-my-openagent"] }, null, 2) + "\n", + ) + + // when + const run = () => ensureBundledNotifyOwnership({ projectDirectory: projectDir, packageRoot }) + + // then + expect(run).toThrow("Unsafe external notify plugin ownership detected") + expect(readConfig(userConfigPath).plugin).toEqual([["kdco/notify@file:../local", {}], "oh-my-openagent"]) + }) + + test("fails loudly for npm-prefixed recognized notify package with workspace source suffix", () => { + // given + const userConfigPath = join(userConfigDir, "opencode.json") + writeFileSync(userConfigPath, JSON.stringify({ plugin: ["npm:kdco/notify@workspace:*", "oh-my-openagent"] }, null, 2) + "\n") + + // when + const run = () => ensureBundledNotifyOwnership({ projectDirectory: projectDir, packageRoot }) + + // then + expect(run).toThrow("Unsafe external notify plugin ownership detected") + expect(readConfig(userConfigPath).plugin).toEqual(["npm:kdco/notify@workspace:*", "oh-my-openagent"]) + }) + test("migrates stale bundled dist/opencode-notify file URL to canonical bundled entry", () => { // given const userConfigPath = join(userConfigDir, "opencode.json") diff --git a/src/shared/bundled-notify-ownership.ts b/src/shared/bundled-notify-ownership.ts index 8b3f6732b..d77456333 100644 --- a/src/shared/bundled-notify-ownership.ts +++ b/src/shared/bundled-notify-ownership.ts @@ -63,7 +63,30 @@ function isPathLikePluginEntry(entry: string): boolean { function isRecognizedExternalNotifyId(entry: string): boolean { const normalized = entry.trim().toLowerCase() - return KNOWN_EXTERNAL_NOTIFY_IDS.some((base) => normalized === base || normalized.startsWith(`${base}@`)) + + return KNOWN_EXTERNAL_NOTIFY_IDS.some((base) => { + if (normalized === base) return true + if (!normalized.startsWith(`${base}@`)) return false + + const versionSuffix = normalized.slice(base.length + 1) + if (versionSuffix.length === 0) return false + if (versionSuffix.includes("@")) return false + if (versionSuffix.includes("://")) return false + if (versionSuffix.includes(":")) return false + if (versionSuffix.includes("/")) return false + if (versionSuffix.includes("\\")) return false + + return /^[a-z0-9.*+!~^<>=| -]+$/i.test(versionSuffix) + }) +} + +function isRecognizedNotifyIdWithUnsupportedSuffix(entry: string): boolean { + const normalized = entry.trim().toLowerCase() + + return KNOWN_EXTERNAL_NOTIFY_IDS.some((base) => { + if (!normalized.startsWith(`${base}@`)) return false + return !isRecognizedExternalNotifyId(normalized) + }) } function stripNpmPrefix(entry: string): string { @@ -97,6 +120,18 @@ function extractPackageIdentifierFromSpec(entry: string): string | null { return stripVersionSuffix(packageSpec) } +function isAliasedRecognizedNotifyTarget(entry: string): boolean { + const normalized = stripNpmPrefix(entry.trim().toLowerCase()) + const aliasSeparatorIndex = normalized.indexOf("@npm:") + if (aliasSeparatorIndex <= 0) return false + + const aliasedTargetSpec = normalized.slice(aliasSeparatorIndex + "@npm:".length) + if (aliasedTargetSpec.length === 0) return false + + const aliasedPackageIdentifier = stripVersionSuffix(stripNpmPrefix(aliasedTargetSpec)) + return aliasedPackageIdentifier === "kdco/notify" +} + function isCustomPackageNotifyCandidate(entry: string): boolean { const packageIdentifier = extractPackageIdentifierFromSpec(entry) if (!packageIdentifier) return false @@ -173,6 +208,24 @@ function classifyPluginEntry(entry: OpenCodePluginEntry, index: number, canonica } } + if (isRecognizedNotifyIdWithUnsupportedSuffix(entry)) { + return { + kind: "unsafe-external", + entry, + index, + reason: "recognized notify package uses unsupported source/custom suffix", + } + } + + if (isAliasedRecognizedNotifyTarget(entry)) { + return { + kind: "unsafe-external", + entry, + index, + reason: "aliased kdco/notify entries are treated as custom notify owners", + } + } + if (isCustomPackageNotifyCandidate(entry)) { return { kind: "unsafe-external", @@ -221,6 +274,24 @@ function classifyPluginEntry(entry: OpenCodePluginEntry, index: number, canonica } } + if (isRecognizedNotifyIdWithUnsupportedSuffix(tupleKey)) { + return { + kind: "unsafe-external", + entry, + index, + reason: "recognized notify package uses unsupported source/custom suffix", + } + } + + if (isAliasedRecognizedNotifyTarget(tupleKey)) { + return { + kind: "unsafe-external", + entry, + index, + reason: "aliased kdco/notify entries are treated as custom notify owners", + } + } + if (isCustomPackageNotifyCandidate(tupleKey)) { return { kind: "unsafe-external",