From e80f5733428ee35172b28fa5bc5aeca6c1f53719 Mon Sep 17 00:00:00 2001 From: Janni Turunen Date: Sat, 11 Apr 2026 00:13:10 +0300 Subject: [PATCH 1/2] fix(opencode): restore external plugin loading in Plugin.init() (#408) - Replace stub Plugin.init() with real implementation that loads internal and external plugins - Create SDK client with Server.App().fetch adapter for in-process routing - Respect OPENCODE_DISABLE_DEFAULT_PLUGINS flag for internal plugins - Load external plugins via PluginLoader.loadExternal() with error reporting - Reset hooks on re-init to prevent accumulation - Fix pre-existing as any casts: use PluginModule type and keyof Hooks - Remove unused imports (Session, NamedError, errorMessage) - Add plugin-loading feature entry to fork-features manifest - Add plugin-loading tests with mockConfig helper --- .fork-features/manifest.json | 28 ++++ packages/opencode/src/plugin/index.ts | 67 +++++++- .../test/plugin/plugin-loading.test.ts | 158 ++++++++++++++++++ 3 files changed, 245 insertions(+), 8 deletions(-) create mode 100644 packages/opencode/test/plugin/plugin-loading.test.ts diff --git a/.fork-features/manifest.json b/.fork-features/manifest.json index e877ce587bf1..aab4e74f6bbe 100644 --- a/.fork-features/manifest.json +++ b/.fork-features/manifest.json @@ -530,6 +530,34 @@ "relatedIssues": [], "absorptionSignals": ["getApfelSuggestion", "apfelPath.*Bun.which", "apfel.*permissive.*permission"] } + }, + "plugin-loading": { + "status": "active", + "description": "Restored plugin loading in init() to load internal and external plugins. Upstream uses Effect-based init(); our fork uses direct imperative loading with PluginLoader.loadExternal(). Diverges from upstream plugin/index.ts which uses Effect Layer pattern.", + "issue": "https://github.com/randomm/opencode/issues/408", + "newFiles": [], + "deletedFiles": [], + "modifiedFiles": ["packages/opencode/src/plugin/index.ts"], + "criticalCode": [ + "Plugin.init", + "PluginLoader.loadExternal", + "INTERNAL_PLUGINS", + "applyPlugin", + "createOpencodeClient", + "Server.App().fetch", + "OPENCODE_DISABLE_DEFAULT_PLUGINS" + ], + "tests": ["packages/opencode/test/plugin/plugin-loading.test.ts"], + "upstreamTracking": { + "relatedPRs": ["anomalyco/opencode#7206"], + "relatedIssues": ["anomalyco/opencode#5887"], + "absorptionSignals": [ + "Plugin.init.*loadExternal", + "createOpencodeClient.*Server.App", + "imperative plugin loading", + "OPENCODE_DISABLE_DEFAULT_PLUGINS" + ] + } } } } diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index f6d84ccaa3fd..b5e37927fd2d 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -1,12 +1,14 @@ -import type { Hooks, PluginInput, Plugin as PluginInstance } from "@opencode-ai/plugin" +import type { Hooks, PluginInput, Plugin as PluginInstance, PluginModule } from "@opencode-ai/plugin" +import { $ } from "bun" import { Log } from "../util/log" import { Flag } from "../flag/flag" import { CodexAuthPlugin } from "./codex" -import { Session } from "../session" -import { NamedError } from "@opencode-ai/util/error" import { CopilotAuthPlugin } from "./github-copilot/copilot" import { PluginLoader } from "./loader" -import { errorMessage } from "@/util/error" +import { Config } from "../config/config" +import { Server } from "../server/server" +import { Instance } from "../project/instance" +import { createOpencodeClient } from "@opencode-ai/sdk" export namespace Plugin { const log = Log.create({ service: "plugin" }) @@ -21,8 +23,9 @@ export namespace Plugin { function getServerPlugin(value: unknown) { if (isServerPlugin(value)) return value if (!value || typeof value !== "object" || !("server" in value)) return - if (!isServerPlugin((value as any).server)) return - return (value as any).server + const server = (value as PluginModule).server + if (!isServerPlugin(server)) return + return server } function getLegacyPlugins(mod: Record) { @@ -52,12 +55,60 @@ export namespace Plugin { } export async function init() { - log.info("plugin system stub - init called") + log.info("plugin init called") + hooks = [] // Reset hooks to prevent accumulation on re-init + + const config = await Config.get() + const client = createOpencodeClient({ + baseUrl: Server.url().origin, + directory: Instance.directory, + fetch: (input: RequestInfo | URL, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init) + return Promise.resolve(Server.App().fetch(request)) + }, + }) + + const input: PluginInput = { + client, + project: Instance.project, + worktree: Instance.worktree, + directory: Instance.directory, + serverUrl: Server.url(), + $: $, + } + + // Load built-in plugins (unless disabled via flag) + if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) { + for (const plugin of INTERNAL_PLUGINS) { + log.info("loading internal plugin", { name: plugin.name }) + const hook = await plugin(input) + hooks.push(hook) + } + } + + // Load external plugins from config directories (e.g. ~/.config/opencode/plugins/*.js) + if (config.plugin && config.plugin.length > 0) { + const origins: Config.PluginOrigin[] = config.plugin.map((spec: string) => ({ spec })) + await PluginLoader.loadExternal({ + items: origins, + kind: "server", + finish: async (loaded) => { + log.info("loaded external plugin", { spec: loaded.spec }) + await applyPlugin(loaded, input) + return loaded + }, + report: { + error: (candidate, _retry, stage, error) => { + log.error("failed to load external plugin", { spec: candidate.plan.spec, stage, error }) + }, + }, + }) + } } export async function trigger(name: string, input: unknown, output: Output): Promise { for (const hook of hooks) { - const fn = (hook as any)[name] + const fn = hook[name as keyof Hooks] as ((input: unknown, output: unknown) => Promise) | undefined if (fn) await fn(input, output) } return output diff --git a/packages/opencode/test/plugin/plugin-loading.test.ts b/packages/opencode/test/plugin/plugin-loading.test.ts new file mode 100644 index 000000000000..4ebdc3ea27c6 --- /dev/null +++ b/packages/opencode/test/plugin/plugin-loading.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import fs from "fs/promises" +import { tmpdir } from "../fixture/fixture" +import { Plugin } from "../../src/plugin" +import { Config } from "../../src/config/config" +import { Instance } from "../../src/project/instance" + +function mockConfig(plugin: string[] = []): Config.Info { + return { plugin } as Config.Info +} + +describe("plugin-loading", () => { + test("init() completes without error when called with empty config", async () => { + await using tmp = await tmpdir({ + config: { model: "test/model" }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Mock Config.get to return no external plugins + const originalGet = Config.get + Config.get = async () => mockConfig() + + try { + // Call init - should complete without throwing + await Plugin.init() + + // Verify init completed - hooks should be populated with internal plugins + const hooks = await Plugin.list() + expect(Array.isArray(hooks)).toBe(true) + expect(hooks.length).toBeGreaterThan(0) + } finally { + Config.get = originalGet + } + }, + }) + }) + + test("init() loads external plugins when config.plugin has entries", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const pluginDir = path.join(dir, ".opencode", "plugin") + await fs.mkdir(pluginDir, { recursive: true }) + + await Bun.write( + path.join(pluginDir, "test-plugin.ts"), + [ + "export default async (input) => ({", + " auth: {", + ' provider: "test",', + " methods: [{ type: 'api', label: 'Test Plugin Auth' }],", + " },", + "})", + "", + ].join("\n"), + ) + + return path.join(pluginDir, "test-plugin.ts") + }, + config: { model: "test/model" }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Mock Config.get to return external plugin path + const originalGet = Config.get + Config.get = async () => mockConfig([tmp.extra as string]) + + try { + // Call init - external plugin should load + await Plugin.init() + + // Verify init completed + const hooks = await Plugin.list() + expect(Array.isArray(hooks)).toBe(true) + } finally { + Config.get = originalGet + } + }, + }) + }, 30000) + + test("init() handles non-existent external plugin gracefully", async () => { + await using tmp = await tmpdir({ + config: { model: "test/model" }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Mock Config.get to return a non-existent plugin + const originalGet = Config.get + Config.get = async () => mockConfig(["file:///non/existent/plugin.js"]) + + try { + // Call init - should NOT throw even with non-existent plugin + await Plugin.init() + + // init completes successfully despite plugin load failure + expect(true).toBe(true) + } finally { + Config.get = originalGet + } + }, + }) + }) + + test("trigger() works after init() is called", async () => { + await using tmp = await tmpdir({ + config: { model: "test/model" }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Mock Config.get to return no external plugins + const originalGet = Config.get + Config.get = async () => mockConfig() + + try { + // Call init first + await Plugin.init() + + // trigger should work without throwing + const result = await Plugin.trigger("nonExistentHook", {}, { original: true }) + expect(result).toEqual({ original: true }) + } finally { + Config.get = originalGet + } + }, + }) + }) + + test("list() returns array of hooks after init()", async () => { + await using tmp = await tmpdir({ + config: { model: "test/model" }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const originalGet = Config.get + Config.get = async () => mockConfig() + + try { + await Plugin.init() + const hooks = await Plugin.list() + expect(Array.isArray(hooks)).toBe(true) + } finally { + Config.get = originalGet + } + }, + }) + }) +}) From 4fedf4c13e0b3c39b53c5a8e587a14925984e15f Mon Sep 17 00:00:00 2001 From: Janni Turunen Date: Sat, 11 Apr 2026 00:26:36 +0300 Subject: [PATCH 2/2] fix(opencode): address code review findings (#409) - Replace Promise.resolve() with async/await on fetch adapter for proper error propagation - Remove explicit Config.PluginOrigin[] type annotation, use TypeScript inference --- packages/opencode/src/plugin/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index b5e37927fd2d..b442c4815cc2 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -62,9 +62,9 @@ export namespace Plugin { const client = createOpencodeClient({ baseUrl: Server.url().origin, directory: Instance.directory, - fetch: (input: RequestInfo | URL, init?: RequestInit) => { + fetch: async (input: RequestInfo | URL, init?: RequestInit) => { const request = input instanceof Request ? input : new Request(input, init) - return Promise.resolve(Server.App().fetch(request)) + return Server.App().fetch(request) }, }) @@ -88,7 +88,7 @@ export namespace Plugin { // Load external plugins from config directories (e.g. ~/.config/opencode/plugins/*.js) if (config.plugin && config.plugin.length > 0) { - const origins: Config.PluginOrigin[] = config.plugin.map((spec: string) => ({ spec })) + const origins = config.plugin.map((spec) => ({ spec })) await PluginLoader.loadExternal({ items: origins, kind: "server",