From 158ccabf24803f403d1581773244a6da076d97c0 Mon Sep 17 00:00:00 2001 From: justsisyphus Date: Tue, 27 Jan 2026 09:24:23 +0900 Subject: [PATCH] fix(notification): prevent false positive plugin detection (#1148) --- src/shared/external-plugin-detector.test.ts | 155 ++++++++++++++++++++ src/shared/external-plugin-detector.ts | 21 ++- 2 files changed, 168 insertions(+), 8 deletions(-) diff --git a/src/shared/external-plugin-detector.test.ts b/src/shared/external-plugin-detector.test.ts index f31ab486..fc560c9c 100644 --- a/src/shared/external-plugin-detector.test.ts +++ b/src/shared/external-plugin-detector.test.ts @@ -118,6 +118,161 @@ describe("external-plugin-detector", () => { }) }) + describe("false positive prevention", () => { + test("should NOT match my-opencode-notifier-fork (suffix variation)", () => { + // #given - plugin with similar name but different suffix + const opencodeDir = path.join(tempDir, ".opencode") + fs.mkdirSync(opencodeDir, { recursive: true }) + fs.writeFileSync( + path.join(opencodeDir, "opencode.json"), + JSON.stringify({ plugin: ["my-opencode-notifier-fork"] }) + ) + + // #when + const result = detectExternalNotificationPlugin(tempDir) + + // #then + expect(result.detected).toBe(false) + expect(result.pluginName).toBeNull() + }) + + test("should NOT match some-other-plugin/opencode-notifier-like (path with similar name)", () => { + // #given - plugin path containing similar substring + const opencodeDir = path.join(tempDir, ".opencode") + fs.mkdirSync(opencodeDir, { recursive: true }) + fs.writeFileSync( + path.join(opencodeDir, "opencode.json"), + JSON.stringify({ plugin: ["some-other-plugin/opencode-notifier-like"] }) + ) + + // #when + const result = detectExternalNotificationPlugin(tempDir) + + // #then + expect(result.detected).toBe(false) + expect(result.pluginName).toBeNull() + }) + + test("should NOT match opencode-notifier-extended (prefix match but different package)", () => { + // #given - plugin with prefix match but extended name + const opencodeDir = path.join(tempDir, ".opencode") + fs.mkdirSync(opencodeDir, { recursive: true }) + fs.writeFileSync( + path.join(opencodeDir, "opencode.json"), + JSON.stringify({ plugin: ["opencode-notifier-extended"] }) + ) + + // #when + const result = detectExternalNotificationPlugin(tempDir) + + // #then + expect(result.detected).toBe(false) + expect(result.pluginName).toBeNull() + }) + + test("should match opencode-notifier exactly", () => { + // #given - exact match + const opencodeDir = path.join(tempDir, ".opencode") + fs.mkdirSync(opencodeDir, { recursive: true }) + fs.writeFileSync( + path.join(opencodeDir, "opencode.json"), + JSON.stringify({ plugin: ["opencode-notifier"] }) + ) + + // #when + const result = detectExternalNotificationPlugin(tempDir) + + // #then + expect(result.detected).toBe(true) + expect(result.pluginName).toBe("opencode-notifier") + }) + + test("should match opencode-notifier@1.2.3 (version suffix)", () => { + // #given - version suffix + const opencodeDir = path.join(tempDir, ".opencode") + fs.mkdirSync(opencodeDir, { recursive: true }) + fs.writeFileSync( + path.join(opencodeDir, "opencode.json"), + JSON.stringify({ plugin: ["opencode-notifier@1.2.3"] }) + ) + + // #when + const result = detectExternalNotificationPlugin(tempDir) + + // #then + expect(result.detected).toBe(true) + expect(result.pluginName).toBe("opencode-notifier") + }) + + test("should match @mohak34/opencode-notifier (scoped package)", () => { + // #given - scoped package + const opencodeDir = path.join(tempDir, ".opencode") + fs.mkdirSync(opencodeDir, { recursive: true }) + fs.writeFileSync( + path.join(opencodeDir, "opencode.json"), + JSON.stringify({ plugin: ["@mohak34/opencode-notifier"] }) + ) + + // #when + const result = detectExternalNotificationPlugin(tempDir) + + // #then + expect(result.detected).toBe(true) + expect(result.pluginName).toContain("opencode-notifier") + }) + + test("should match npm:opencode-notifier (npm prefix)", () => { + // #given - npm prefix + const opencodeDir = path.join(tempDir, ".opencode") + fs.mkdirSync(opencodeDir, { recursive: true }) + fs.writeFileSync( + path.join(opencodeDir, "opencode.json"), + JSON.stringify({ plugin: ["npm:opencode-notifier"] }) + ) + + // #when + const result = detectExternalNotificationPlugin(tempDir) + + // #then + expect(result.detected).toBe(true) + expect(result.pluginName).toBe("opencode-notifier") + }) + + test("should match npm:opencode-notifier@2.0.0 (npm prefix with version)", () => { + // #given - npm prefix with version + const opencodeDir = path.join(tempDir, ".opencode") + fs.mkdirSync(opencodeDir, { recursive: true }) + fs.writeFileSync( + path.join(opencodeDir, "opencode.json"), + JSON.stringify({ plugin: ["npm:opencode-notifier@2.0.0"] }) + ) + + // #when + const result = detectExternalNotificationPlugin(tempDir) + + // #then + expect(result.detected).toBe(true) + expect(result.pluginName).toBe("opencode-notifier") + }) + + test("should match file:///path/to/opencode-notifier (file path)", () => { + // #given - file path + const opencodeDir = path.join(tempDir, ".opencode") + fs.mkdirSync(opencodeDir, { recursive: true }) + fs.writeFileSync( + path.join(opencodeDir, "opencode.json"), + JSON.stringify({ plugin: ["file:///home/user/plugins/opencode-notifier"] }) + ) + + // #when + const result = detectExternalNotificationPlugin(tempDir) + + // #then + expect(result.detected).toBe(true) + expect(result.pluginName).toBe("opencode-notifier") + }) + }) + describe("getNotificationConflictWarning", () => { test("should generate warning message with plugin name", () => { // #when diff --git a/src/shared/external-plugin-detector.ts b/src/shared/external-plugin-detector.ts index ed7f2ab2..5cc69a53 100644 --- a/src/shared/external-plugin-detector.ts +++ b/src/shared/external-plugin-detector.ts @@ -71,14 +71,19 @@ function loadOpencodePlugins(directory: string): string[] { function matchesNotificationPlugin(entry: string): string | null { const normalized = entry.toLowerCase() for (const known of KNOWN_NOTIFICATION_PLUGINS) { - if ( - normalized === known || - normalized.startsWith(`${known}@`) || - normalized.includes(`/${known}`) || - normalized.endsWith(`/${known}`) - ) { - return known - } + // Exact match + if (normalized === known) return known + // Version suffix: "opencode-notifier@1.2.3" + if (normalized.startsWith(`${known}@`)) return known + // Scoped package: "@mohak34/opencode-notifier" or "@mohak34/opencode-notifier@1.2.3" + if (normalized === `@mohak34/${known}` || normalized.startsWith(`@mohak34/${known}@`)) return known + // npm: prefix + if (normalized === `npm:${known}` || normalized.startsWith(`npm:${known}@`)) return known + // file:// path ending exactly with package name + if (normalized.startsWith("file://") && ( + normalized.endsWith(`/${known}`) || + normalized.endsWith(`\\${known}`) + )) return known } return null }