diff --git a/src/shared/bundled-notify-ownership.test.ts b/src/shared/bundled-notify-ownership.test.ts index 19a7fa679..deb08912c 100644 --- a/src/shared/bundled-notify-ownership.test.ts +++ b/src/shared/bundled-notify-ownership.test.ts @@ -111,6 +111,32 @@ describe("ensureBundledNotifyOwnership", () => { expect(readConfig(userConfigPath).plugin).toEqual(["team-notify-center", "oh-my-openagent", canonicalEntry]) }) + test("fails loudly for custom package-based notify plugin id", () => { + // given + const userConfigPath = join(userConfigDir, "opencode.json") + writeFileSync(userConfigPath, JSON.stringify({ plugin: ["@custom/opencode-notify", "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(["@custom/opencode-notify", "oh-my-openagent"]) + }) + + test("fails loudly for custom tuple-based notify package id", () => { + // given + const userConfigPath = join(userConfigDir, "opencode.json") + writeFileSync(userConfigPath, JSON.stringify({ plugin: [["npm:@custom/opencode-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([["npm:@custom/opencode-notify@1.2.3", {}], "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 91b93ac5e..d9dd9202c 100644 --- a/src/shared/bundled-notify-ownership.ts +++ b/src/shared/bundled-notify-ownership.ts @@ -66,6 +66,35 @@ function isRecognizedExternalNotifyId(entry: string): boolean { return KNOWN_EXTERNAL_NOTIFY_IDS.some((base) => normalized === base || normalized.startsWith(`${base}@`)) } +function stripNpmPrefix(entry: string): string { + return entry.startsWith("npm:") ? entry.slice("npm:".length) : entry +} + +function stripVersionSuffix(entry: string): string { + const trimmed = entry.trim() + const versionSeparatorIndex = trimmed.lastIndexOf("@") + if (versionSeparatorIndex <= 0) return trimmed + + const slashIndex = trimmed.indexOf("/") + if (trimmed.startsWith("@") && versionSeparatorIndex <= slashIndex) { + return trimmed + } + + return trimmed.slice(0, versionSeparatorIndex) +} + +function isCustomPackageNotifyCandidate(entry: string): boolean { + const normalized = stripNpmPrefix(entry.trim().toLowerCase()) + if (normalized.length === 0) return false + if (normalized.includes("://")) return false + + const packageIdentifier = stripVersionSuffix(normalized) + if (packageIdentifier === "opencode-notify") return true + if (/^@[a-z0-9._-]+\/opencode-notify$/i.test(packageIdentifier)) return true + if (/^[a-z0-9._-]+\/opencode-notify$/i.test(packageIdentifier)) return true + return false +} + function normalizePathForComparison(pathValue: string): string { return resolve(pathValue).replace(/\\/g, "/").replace(/\/+$/, "") } @@ -133,6 +162,15 @@ function classifyPluginEntry(entry: OpenCodePluginEntry, index: number, canonica } } + if (isCustomPackageNotifyCandidate(entry)) { + return { + kind: "unsafe-external", + entry, + index, + reason: "notify package entry is not an exact recognized kdco/notify identifier", + } + } + return { kind: "other", entry, index } } @@ -172,6 +210,15 @@ function classifyPluginEntry(entry: OpenCodePluginEntry, index: number, canonica } } + if (isCustomPackageNotifyCandidate(tupleKey)) { + return { + kind: "unsafe-external", + entry, + index, + reason: "notify package tuple entry is not an exact recognized kdco/notify identifier", + } + } + return { kind: "other", entry, index } }