diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index 1d80118912..c3c96023d5 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -22,6 +22,7 @@ import { isDeprecatedPlugin, pluginSource, readPluginId, + resolvePluginEntrypoint, resolvePluginId, resolvePluginTarget, type PluginSource, @@ -199,14 +200,20 @@ async function loadExternalPlugin( const source = pluginSource(spec) const root = resolveRoot(source === "file" ? spec : target) const install_theme = createThemeInstaller(meta, root, spec) - const mod = await import(target) + const entry = await resolvePluginEntrypoint(spec, target, "tui").catch((error) => { + fail("failed to resolve tui plugin entry", { path: spec, target, retry, error }) + return + }) + if (!entry) return + + const mod = await import(entry) .then((raw) => { const mod = getDefaultPlugin(raw) as TuiPluginModule | undefined if (!mod?.tui) throw new TypeError(`Plugin ${spec} must default export an object with tui()`) return mod }) .catch((error) => { - fail("failed to load tui plugin", { path: spec, target, retry, error }) + fail("failed to load tui plugin", { path: spec, target: entry, retry, error }) return }) if (!mod) return diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index ce51b203bf..8e42bc3ee3 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -14,7 +14,13 @@ import { Effect, Layer, ServiceMap, Stream } from "effect" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" import { errorMessage } from "@/util/error" -import { getDefaultPlugin, isDeprecatedPlugin, parsePluginSpecifier, resolvePluginTarget } from "./shared" +import { + getDefaultPlugin, + isDeprecatedPlugin, + parsePluginSpecifier, + resolvePluginEntrypoint, + resolvePluginTarget, +} from "./shared" export namespace Plugin { const log = Log.create({ service: "plugin" }) @@ -103,9 +109,21 @@ export namespace Plugin { const target = await resolvePlugin(spec) if (!target) return - const mod = await import(target).catch((err) => { + const entry = await resolvePluginEntrypoint(spec, target, "server").catch((err) => { const message = errorMessage(err) - log.error("failed to load plugin", { path: spec, error: message }) + log.error("failed to resolve plugin server entry", { path: spec, target, error: message }) + Bus.publish(Session.Event.Error, { + error: new NamedError.Unknown({ + message: `Failed to load plugin ${spec}: ${message}`, + }).toObject(), + }) + return + }) + if (!entry) return + + const mod = await import(entry).catch((err) => { + const message = errorMessage(err) + log.error("failed to load plugin", { path: spec, target: entry, error: message }) Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message: `Failed to load plugin ${spec}: ${message}`, diff --git a/packages/opencode/src/plugin/shared.ts b/packages/opencode/src/plugin/shared.ts index 55060c7138..c1f7232acc 100644 --- a/packages/opencode/src/plugin/shared.ts +++ b/packages/opencode/src/plugin/shared.ts @@ -19,11 +19,53 @@ export function parsePluginSpecifier(spec: string) { } export type PluginSource = "file" | "npm" +export type PluginKind = "server" | "tui" export function pluginSource(spec: string): PluginSource { return spec.startsWith("file://") ? "file" : "npm" } +function hasEntrypoint(json: Record, kind: PluginKind) { + if (!isRecord(json.exports)) return false + return `./${kind}` in json.exports +} + +function readEntrypointName(source: PluginSource, spec: string, json: Record) { + if (source === "npm") return parsePluginSpecifier(spec).pkg + if (typeof json.name !== "string") return + const value = json.name.trim() + if (!value) return + return value +} + +function readEntrypointPath(file: string) { + if (file.startsWith("file://")) return fileURLToPath(file) + if (path.isAbsolute(file) || /^[A-Za-z]:[\\/]/.test(file)) return file + throw new TypeError(`Plugin entry "${file}" is not a file path`) +} + +export async function resolvePluginEntrypoint(spec: string, target: string, kind: PluginKind) { + const source = pluginSource(spec) + const pkg = await readPluginPackage(target).catch(() => undefined) + if (!pkg) return target + if (!hasEntrypoint(pkg.json, kind)) return target + + const name = readEntrypointName(source, spec, pkg.json) + if (!name) { + throw new TypeError(`Plugin package ${pkg.pkg} must define package.json name to export ./${kind}`) + } + + const ref = pathToFileURL(pkg.pkg).href + const entry = import.meta.resolve!(`${name}/${kind}`, ref) + const root = Filesystem.resolve(pkg.dir) + const next = Filesystem.resolve(readEntrypointPath(entry)) + if (!Filesystem.contains(root, next)) { + throw new Error(`Plugin ${spec} resolved ${kind} entry outside plugin directory`) + } + + return pathToFileURL(next).href +} + export function isPathPluginSpec(spec: string) { return spec.startsWith("file://") || spec.startsWith(".") || path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec) } diff --git a/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts b/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts new file mode 100644 index 0000000000..2459bddb56 --- /dev/null +++ b/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts @@ -0,0 +1,192 @@ +import { expect, spyOn, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { tmpdir } from "../../fixture/fixture" +import { createTuiPluginApi } from "../../fixture/tui-plugin" +import { TuiConfig } from "../../../src/config/tui" +import { BunProc } from "../../../src/bun" + +const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") + +function rec(value: unknown) { + if (!value || typeof value !== "object") return + return Object.fromEntries(Object.entries(value)) +} + +test("loads npm tui plugin from package ./tui export", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const mod = path.join(dir, "mods", "acme-plugin") + const marker = path.join(dir, "tui-called.txt") + await fs.mkdir(mod, { recursive: true }) + + await Bun.write( + path.join(mod, "package.json"), + JSON.stringify( + { + name: "acme-plugin", + type: "module", + exports: { + ".": "./index.js", + "./server": "./server.js", + "./tui": "./tui.js", + }, + }, + null, + 2, + ), + ) + await Bun.write(path.join(mod, "index.js"), 'import "./main-throws.js"\nexport default {}\n') + await Bun.write(path.join(mod, "main-throws.js"), 'throw new Error("main loaded")\n') + await Bun.write(path.join(mod, "server.js"), "export default {}\n") + await Bun.write( + path.join(mod, "tui.js"), + [ + "export default {", + ' id: "demo.tui.export",', + " tui: async (_api, options) => {", + " if (!options?.marker) return", + ` await Bun.write(${JSON.stringify(marker)}, "called")`, + " },", + "}", + "", + ].join("\n"), + ) + + return { + mod, + marker, + spec: "acme-plugin@1.0.0", + } + }, + }) + + process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") + const get = spyOn(TuiConfig, "get").mockResolvedValue({ + plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]], + plugin_meta: { + [tmp.extra.spec]: { + scope: "local", + source: path.join(tmp.path, "tui.json"), + }, + }, + }) + const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() + const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) + const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod) + + try { + await TuiPluginRuntime.init(createTuiPluginApi()) + + await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called") + expect(TuiPluginRuntime.list().find((item) => item.id === "demo.tui.export")).toEqual({ + id: "demo.tui.export", + source: "npm", + spec: tmp.extra.spec, + target: tmp.extra.mod, + enabled: true, + active: true, + }) + } finally { + await TuiPluginRuntime.dispose() + install.mockRestore() + cwd.mockRestore() + get.mockRestore() + wait.mockRestore() + delete process.env.OPENCODE_PLUGIN_META_FILE + } +}) + +test("rejects npm tui export that resolves outside plugin directory", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const mod = path.join(dir, "mods", "acme-plugin") + const outside = path.join(dir, "outside") + const marker = path.join(dir, "outside-called.txt") + await fs.mkdir(mod, { recursive: true }) + await fs.mkdir(outside, { recursive: true }) + + await Bun.write( + path.join(mod, "package.json"), + JSON.stringify( + { + name: "acme-plugin", + type: "module", + exports: { + ".": "./index.js", + "./tui": "./escape/tui.js", + }, + }, + null, + 2, + ), + ) + await Bun.write(path.join(mod, "index.js"), "export default {}\n") + await Bun.write( + path.join(outside, "tui.js"), + [ + "export default {", + ' id: "demo.outside",', + " tui: async () => {", + ` await Bun.write(${JSON.stringify(marker)}, "outside")`, + " },", + "}", + "", + ].join("\n"), + ) + await fs.symlink(outside, path.join(mod, "escape"), process.platform === "win32" ? "junction" : "dir") + + return { + mod, + marker, + spec: "acme-plugin@1.0.0", + } + }, + }) + + process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") + const get = spyOn(TuiConfig, "get").mockResolvedValue({ + plugin: [tmp.extra.spec], + plugin_meta: { + [tmp.extra.spec]: { + scope: "local", + source: path.join(tmp.path, "tui.json"), + }, + }, + }) + const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() + const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) + const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod) + const err = spyOn(console, "error").mockImplementation(() => {}) + + try { + await TuiPluginRuntime.init(createTuiPluginApi()) + + await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow() + expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false) + + const hit = err.mock.calls.find( + (item) => typeof item[0] === "string" && item[0].includes("failed to resolve tui plugin entry"), + ) + expect(hit).toBeDefined() + if (!hit) return + + const data = rec(hit[1]) + expect(data).toBeDefined() + if (!data) return + expect(data.path).toBe(tmp.extra.spec) + expect(data.target).toBe(tmp.extra.mod) + const info = rec(data.error) + expect(info).toBeDefined() + if (!info) return + expect(String(info.message ?? "")).toContain("outside plugin directory") + } finally { + await TuiPluginRuntime.dispose() + err.mockRestore() + install.mockRestore() + cwd.mockRestore() + get.mockRestore() + wait.mockRestore() + delete process.env.OPENCODE_PLUGIN_META_FILE + } +}) diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts index 1ada81dd63..572f790faf 100644 --- a/packages/opencode/test/plugin/loader-shared.test.ts +++ b/packages/opencode/test/plugin/loader-shared.test.ts @@ -196,6 +196,127 @@ describe("plugin.loader.shared", () => { } }) + test("loads npm server plugin from package ./server export", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const mod = path.join(dir, "mods", "acme-plugin") + const mark = path.join(dir, "server-called.txt") + await fs.mkdir(mod, { recursive: true }) + + await Bun.write( + path.join(mod, "package.json"), + JSON.stringify( + { + name: "acme-plugin", + type: "module", + exports: { + ".": "./index.js", + "./server": "./server.js", + "./tui": "./tui.js", + }, + }, + null, + 2, + ), + ) + await Bun.write(path.join(mod, "index.js"), 'import "./main-throws.js"\nexport default {}\n') + await Bun.write(path.join(mod, "main-throws.js"), 'throw new Error("main loaded")\n') + await Bun.write( + path.join(mod, "server.js"), + [ + "export default {", + " server: async () => {", + ` await Bun.write(${JSON.stringify(mark)}, "called")`, + " return {}", + " },", + "}", + "", + ].join("\n"), + ) + await Bun.write(path.join(mod, "tui.js"), "export default {}\n") + + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["acme-plugin@1.0.0"] }, null, 2)) + + return { + mod, + mark, + } + }, + }) + + const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod) + + try { + await load(tmp.path) + expect(await Bun.file(tmp.extra.mark).text()).toBe("called") + } finally { + install.mockRestore() + } + }) + + test("rejects npm server export that resolves outside plugin directory", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const mod = path.join(dir, "mods", "acme-plugin") + const outside = path.join(dir, "outside") + const mark = path.join(dir, "outside-server.txt") + await fs.mkdir(mod, { recursive: true }) + await fs.mkdir(outside, { recursive: true }) + + await Bun.write( + path.join(mod, "package.json"), + JSON.stringify( + { + name: "acme-plugin", + type: "module", + exports: { + ".": "./index.js", + "./server": "./escape/server.js", + }, + }, + null, + 2, + ), + ) + await Bun.write(path.join(mod, "index.js"), "export default {}\n") + await Bun.write( + path.join(outside, "server.js"), + [ + "export default {", + " server: async () => {", + ` await Bun.write(${JSON.stringify(mark)}, "outside")`, + " return {}", + " },", + "}", + "", + ].join("\n"), + ) + await fs.symlink(outside, path.join(mod, "escape"), process.platform === "win32" ? "junction" : "dir") + + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["acme-plugin"] }, null, 2)) + + return { + mod, + mark, + } + }, + }) + + const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod) + + try { + const errors = await errs(tmp.path) + const called = await Bun.file(tmp.extra.mark) + .text() + .then(() => true) + .catch(() => false) + expect(called).toBe(false) + expect(errors.some((x) => x.includes("outside plugin directory"))).toBe(true) + } finally { + install.mockRestore() + } + }) + test("skips legacy codex and copilot auth plugin specs", async () => { await using tmp = await tmpdir({ init: async (dir) => {