index.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. import type {
  2. Hooks,
  3. PluginInput,
  4. Plugin as PluginInstance,
  5. PluginModule,
  6. WorkspaceAdapter as PluginWorkspaceAdapter,
  7. } from "@opencode-ai/plugin"
  8. import { Config } from "@/config/config"
  9. import { Bus } from "../bus"
  10. import * as Log from "@opencode-ai/core/util/log"
  11. import { createOpencodeClient } from "@opencode-ai/sdk"
  12. import { ServerAuth } from "@/server/auth"
  13. import { CodexAuthPlugin } from "./codex"
  14. import { Session } from "@/session/session"
  15. import { NamedError } from "@opencode-ai/core/util/error"
  16. import { CopilotAuthPlugin } from "./github-copilot/copilot"
  17. import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
  18. import { PoeAuthPlugin } from "opencode-poe-auth"
  19. import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare"
  20. import { AzureAuthPlugin } from "./azure"
  21. import { DigitalOceanAuthPlugin } from "./digitalocean"
  22. import { XaiAuthPlugin } from "./xai"
  23. import { Effect, Layer, Context, Stream } from "effect"
  24. import { EffectBridge } from "@/effect/bridge"
  25. import { InstanceState } from "@/effect/instance-state"
  26. import { errorMessage } from "@/util/error"
  27. import { PluginLoader } from "./loader"
  28. import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared"
  29. import { registerAdapter } from "@/control-plane/adapters"
  30. import type { WorkspaceAdapter } from "@/control-plane/types"
  31. import { RuntimeFlags } from "@/effect/runtime-flags"
  32. const log = Log.create({ service: "plugin" })
  33. type State = {
  34. hooks: Hooks[]
  35. }
  36. // Hook names that follow the (input, output) => Promise<void> trigger pattern
  37. type TriggerName = {
  38. [K in keyof Hooks]-?: NonNullable<Hooks[K]> extends (input: any, output: any) => Promise<void> ? K : never
  39. }[keyof Hooks]
  40. export interface Interface {
  41. readonly trigger: <
  42. Name extends TriggerName,
  43. Input = Parameters<Required<Hooks>[Name]>[0],
  44. Output = Parameters<Required<Hooks>[Name]>[1],
  45. >(
  46. name: Name,
  47. input: Input,
  48. output: Output,
  49. ) => Effect.Effect<Output>
  50. readonly list: () => Effect.Effect<Hooks[]>
  51. readonly init: () => Effect.Effect<void>
  52. }
  53. export class Service extends Context.Service<Service, Interface>()("@opencode/Plugin") {}
  54. // Built-in plugins that are directly imported (not installed from npm)
  55. const INTERNAL_PLUGINS: PluginInstance[] = [
  56. CodexAuthPlugin,
  57. CopilotAuthPlugin,
  58. GitlabAuthPlugin,
  59. PoeAuthPlugin,
  60. CloudflareWorkersAuthPlugin,
  61. CloudflareAIGatewayAuthPlugin,
  62. AzureAuthPlugin,
  63. DigitalOceanAuthPlugin,
  64. XaiAuthPlugin,
  65. ]
  66. function isServerPlugin(value: unknown): value is PluginInstance {
  67. return typeof value === "function"
  68. }
  69. function getServerPlugin(value: unknown) {
  70. if (isServerPlugin(value)) return value
  71. if (!value || typeof value !== "object" || !("server" in value)) return
  72. if (!isServerPlugin(value.server)) return
  73. return value.server
  74. }
  75. function getLegacyPlugins(mod: Record<string, unknown>) {
  76. const seen = new Set<unknown>()
  77. const result: PluginInstance[] = []
  78. for (const entry of Object.values(mod)) {
  79. if (seen.has(entry)) continue
  80. seen.add(entry)
  81. const plugin = getServerPlugin(entry)
  82. if (!plugin) throw new TypeError("Plugin export is not a function")
  83. result.push(plugin)
  84. }
  85. return result
  86. }
  87. async function applyPlugin(load: PluginLoader.Loaded, input: PluginInput, hooks: Hooks[]) {
  88. const plugin = readV1Plugin(load.mod, load.spec, "server", "detect")
  89. if (plugin) {
  90. await resolvePluginId(load.source, load.spec, load.target, readPluginId(plugin.id, load.spec), load.pkg)
  91. hooks.push(await (plugin as PluginModule).server(input, load.options))
  92. return
  93. }
  94. for (const server of getLegacyPlugins(load.mod)) {
  95. hooks.push(await server(input, load.options))
  96. }
  97. }
  98. export const layer = Layer.effect(
  99. Service,
  100. Effect.gen(function* () {
  101. const bus = yield* Bus.Service
  102. const config = yield* Config.Service
  103. const flags = yield* RuntimeFlags.Service
  104. const state = yield* InstanceState.make<State>(
  105. Effect.fn("Plugin.state")(function* (ctx) {
  106. const hooks: Hooks[] = []
  107. const bridge = yield* EffectBridge.make()
  108. function publishPluginError(message: string) {
  109. bridge.fork(bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }))
  110. }
  111. const { Server } = yield* Effect.promise(() => import("../server/server"))
  112. const client = createOpencodeClient({
  113. baseUrl: "http://localhost:4096",
  114. directory: ctx.directory,
  115. headers: ServerAuth.headers(),
  116. fetch: async (...args) => Server.Default().app.fetch(...args),
  117. })
  118. const cfg = yield* config.get()
  119. const input: PluginInput = {
  120. client,
  121. project: ctx.project,
  122. worktree: ctx.worktree,
  123. directory: ctx.directory,
  124. experimental_workspace: {
  125. register(type: string, adapter: PluginWorkspaceAdapter) {
  126. registerAdapter(ctx.project.id, type, adapter as WorkspaceAdapter)
  127. },
  128. },
  129. get serverUrl(): URL {
  130. return Server.url ?? new URL("http://localhost:4096")
  131. },
  132. // @ts-expect-error
  133. $: typeof Bun === "undefined" ? undefined : Bun.$,
  134. }
  135. for (const plugin of flags.disableDefaultPlugins ? [] : INTERNAL_PLUGINS) {
  136. log.info("loading internal plugin", { name: plugin.name })
  137. const init = yield* Effect.tryPromise({
  138. try: () => plugin(input),
  139. catch: (err) => {
  140. log.error("failed to load internal plugin", { name: plugin.name, error: err })
  141. },
  142. }).pipe(Effect.option)
  143. if (init._tag === "Some") hooks.push(init.value)
  144. }
  145. const plugins = flags.pure ? [] : (cfg.plugin_origins ?? [])
  146. if (flags.pure && cfg.plugin_origins?.length) {
  147. log.info("skipping external plugins in pure mode", { count: cfg.plugin_origins.length })
  148. }
  149. if (plugins.length) yield* config.waitForDependencies()
  150. const loaded = yield* Effect.promise(() =>
  151. PluginLoader.loadExternal({
  152. items: plugins,
  153. kind: "server",
  154. report: {
  155. start(candidate) {
  156. log.info("loading plugin", { path: candidate.plan.spec })
  157. },
  158. missing(candidate, _retry, message) {
  159. log.warn("plugin has no server entrypoint", { path: candidate.plan.spec, message })
  160. },
  161. error(candidate, _retry, stage, error, resolved) {
  162. const spec = candidate.plan.spec
  163. const cause = error instanceof Error ? (error.cause ?? error) : error
  164. const message = stage === "load" ? errorMessage(error) : errorMessage(cause)
  165. if (stage === "install") {
  166. const parsed = parsePluginSpecifier(spec)
  167. log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: message })
  168. publishPluginError(`Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`)
  169. return
  170. }
  171. if (stage === "compatibility") {
  172. log.warn("plugin incompatible", { path: spec, error: message })
  173. publishPluginError(`Plugin ${spec} skipped: ${message}`)
  174. return
  175. }
  176. if (stage === "entry") {
  177. log.error("failed to resolve plugin server entry", { path: spec, error: message })
  178. publishPluginError(`Failed to load plugin ${spec}: ${message}`)
  179. return
  180. }
  181. log.error("failed to load plugin", { path: spec, target: resolved?.entry, error: message })
  182. publishPluginError(`Failed to load plugin ${spec}: ${message}`)
  183. },
  184. },
  185. }),
  186. )
  187. for (const load of loaded) {
  188. if (!load) continue
  189. // Keep plugin execution sequential so hook registration and execution
  190. // order remains deterministic across plugin runs.
  191. yield* Effect.tryPromise({
  192. try: () => applyPlugin(load, input, hooks),
  193. catch: (err) => {
  194. const message = errorMessage(err)
  195. log.error("failed to load plugin", { path: load.spec, error: message })
  196. return message
  197. },
  198. }).pipe(
  199. Effect.catch(() => {
  200. // TODO: make proper events for this
  201. // bus.publish(Session.Event.Error, {
  202. // error: new NamedError.Unknown({
  203. // message: `Failed to load plugin ${load.spec}: ${message}`,
  204. // }).toObject(),
  205. // })
  206. return Effect.void
  207. }),
  208. )
  209. }
  210. // Notify plugins of current config
  211. for (const hook of hooks) {
  212. yield* Effect.tryPromise({
  213. try: () => Promise.resolve((hook as any).config?.(cfg)),
  214. catch: (err) => {
  215. log.error("plugin config hook failed", { error: err })
  216. },
  217. }).pipe(Effect.ignore)
  218. }
  219. yield* Effect.addFinalizer(() =>
  220. Effect.forEach(
  221. hooks,
  222. (hook) =>
  223. Effect.tryPromise({
  224. try: () => Promise.resolve(hook.dispose?.()),
  225. catch: (error) => {
  226. log.error("plugin dispose hook failed", { error })
  227. },
  228. }).pipe(Effect.ignore),
  229. { discard: true },
  230. ),
  231. )
  232. // Subscribe to bus events, fiber interrupted when scope closes
  233. yield* (yield* bus.subscribeAll()).pipe(
  234. Stream.runForEach((input) =>
  235. Effect.sync(() => {
  236. for (const hook of hooks) {
  237. void hook["event"]?.({ event: input as any })
  238. }
  239. }),
  240. ),
  241. Effect.forkScoped,
  242. )
  243. return { hooks }
  244. }),
  245. )
  246. const trigger = Effect.fn("Plugin.trigger")(function* <
  247. Name extends TriggerName,
  248. Input = Parameters<Required<Hooks>[Name]>[0],
  249. Output = Parameters<Required<Hooks>[Name]>[1],
  250. >(name: Name, input: Input, output: Output) {
  251. if (!name) return output
  252. const s = yield* InstanceState.get(state)
  253. for (const hook of s.hooks) {
  254. const fn = hook[name] as any
  255. if (!fn) continue
  256. yield* Effect.promise(async () => fn(input, output))
  257. }
  258. return output
  259. })
  260. const list = Effect.fn("Plugin.list")(function* () {
  261. const s = yield* InstanceState.get(state)
  262. return s.hooks
  263. })
  264. const init = Effect.fn("Plugin.init")(function* () {
  265. yield* InstanceState.get(state)
  266. })
  267. return Service.of({ trigger, list, init })
  268. }),
  269. )
  270. export const defaultLayer = layer.pipe(
  271. Layer.provide(Bus.layer),
  272. Layer.provide(Config.defaultLayer),
  273. Layer.provide(RuntimeFlags.defaultLayer),
  274. )
  275. export * as Plugin from "."