Files
magpie/internal/plugin/host.js
T
yetone a31f97b30d plugin: OpenCode's provider plugins sign in to subscriptions in magpie — an npm package OpenCode users install for a plan (its auth hook: loader, methods, oauth authorize/callback, api keys, chat.headers) runs in a Bun host magpie downloads the first time a plugin needs it, speaking JSON-RPC over stdio; its sign-ins are kept in plugin-auth.json (0600). A provider a plugin signs in to is a provider like the others: its models the plugin lists, each spoken to on the API its AI SDK package speaks (anthropic → Messages, openai/azure → Responses, google → Gemini, Copilot's GPT-5+ → Responses, else chat completions), its requests carried by the plugin's fetch to the base URL its loader gives; an id magpie already has is <id>-plugin. CLI: magpie plugin [list|add|rm|update|on|off|login|logout]. App: Settings → Plugins adds, updates, switches off and removes them and says why one didn't load; Add provider → From plugins signs in with the plugin's methods, its questions (picks and checked text), the browser and a pasted code or a key, followed as the built-in sign-ins are; removing the account signs out. The built-in subscriptions stay. TestFakePlugin, TestPluginProvider (chat, Anthropic and Gemini models through the gateway) and TestPluginSignIn (a wrong code fails, the right one signs in, removing signs out, a key) run a fake plugin on Bun; plugin-signin.test.cjs (Chromium, WebKit, en/zh) covers the add sheet and Settings. signin-callback, add-sheet, zed-sub, account-proxy, own-signin-remove, routing-signin, click-scroll GUI tests pass; go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass (TestUserPath, TestClaudeSignedOut, TestClaudeAccountNamedAsItsLogin, TestClaudeIdentityServedWhileAsked, known flakes under load, pass alone). Not yet tried with a real plugin and account.
2026-09-30 16:44:32 +08:00

654 lines
23 KiB
JavaScript

// magpie's plugin host: runs OpenCode (v1) server plugins under Bun and
// answers magpie over stdin/stdout, one JSON message a line.
//
// magpie → host: {id, method, params}; the host answers {id, result} or
// {id, error}. A fetch streams its reply first: {id, event: "head", status,
// headers}, then {id, event: "chunk", data} (base64) as the body comes, then
// {id, result: null}. {method: "abort", params: {id}} cancels one.
// host → magpie, unasked: {event: "log", level, message} and {event:
// "toast", ...} (what a plugin logs or shows), {event: "auth", provider}
// (a sign-in the host saved or refreshed).
//
// The plugins get what OpenCode hands them (PluginInput): a client whose
// auth.set saves to magpie's plugin-auth.json in OpenCode's auth.json shape,
// tui.showToast and app.log, config.get; Bun's $; the folder magpie keeps
// its files in. What else a plugin asks the client for answers {data:
// undefined}.
import fs from "node:fs"
import path from "node:path"
import readline from "node:readline"
import { pathToFileURL } from "node:url"
const rpcWrite = process.stdout.write.bind(process.stdout)
const send = (msg) => rpcWrite(JSON.stringify(msg) + "\n")
// A plugin writing to stdout would break the protocol: everything it
// prints goes to stderr, which magpie logs.
const toErr = (...xs) => process.stderr.write(xs.map((x) => (typeof x === "string" ? x : Bun.inspect(x))).join(" ") + "\n")
console.log = console.info = console.debug = console.warn = toErr
process.stdout.write = (chunk, enc, cb) => process.stderr.write(chunk, enc, cb)
let authPath = ""
let modelsDevPath = ""
let directory = process.cwd()
let userConfig = {}
const hooks = [] // {spec, hooks}
const loaded = [] // {spec, id, error}
const loaders = new Map() // provider → options the auth loader returned
const sessions = new Map() // oauth sign-in in progress → its authorize result
const inflight = new Map() // fetch id → AbortController
let config = { provider: {} } // what the plugins' config hooks made of it
// ---- auth.json ---------------------------------------------------------------
function readAuth() {
try {
const v = JSON.parse(fs.readFileSync(authPath, "utf8"))
return v && typeof v === "object" ? v : {}
} catch {
return {}
}
}
function writeAuth(all) {
fs.mkdirSync(path.dirname(authPath), { recursive: true })
const tmp = authPath + ".tmp-" + process.pid
fs.writeFileSync(tmp, JSON.stringify(all, null, 2) + "\n", { mode: 0o600 })
fs.renameSync(tmp, authPath)
}
function setAuth(id, info) {
const all = readAuth()
all[id] = info
writeAuth(all)
loaders.delete(id)
send({ event: "auth", provider: id })
}
function removeAuth(id) {
const all = readAuth()
delete all[id]
writeAuth(all)
loaders.delete(id)
send({ event: "auth", provider: id })
}
// ---- the client plugins are given --------------------------------------------
function stub(name) {
return new Proxy(async () => ({ data: undefined }), {
get(_, key) {
if (key === "then") return undefined
return stub(name + "." + String(key))
},
apply() {
return Promise.resolve({ data: undefined })
},
})
}
function makeClient() {
const known = {
auth: {
// OpenCode's SDK: auth.set({path: {id}, body})
set: async (opts) => {
const id = opts?.path?.id ?? opts?.id ?? opts?.providerID
const body = opts?.body ?? opts?.auth
if (typeof id === "string" && body && typeof body === "object") {
// OpenCode keeps what the plugin gives it, saving refreshed
// tokens over the old; nothing is merged
const prev = readAuth()[id]
if (JSON.stringify(prev) !== JSON.stringify(body)) setAuth(id, body)
}
return { data: true }
},
remove: async (opts) => {
const id = opts?.path?.id ?? opts?.id
if (typeof id === "string") removeAuth(id)
return { data: true }
},
},
tui: {
showToast: async (opts) => {
const b = opts?.body ?? opts ?? {}
send({ event: "toast", title: b.title ?? "", message: String(b.message ?? ""), variant: b.variant ?? "info" })
return { data: true }
},
},
app: {
log: async (opts) => {
const b = opts?.body ?? opts ?? {}
send({ event: "log", level: b.level ?? "info", message: `[${b.service ?? "plugin"}] ${b.message ?? ""}` })
return { data: true }
},
},
config: {
get: async () => ({ data: config }),
},
}
const wrap = (obj, name) =>
new Proxy(obj, {
get(target, key) {
if (key in target) {
const v = target[key]
return v && typeof v === "object" ? wrap(v, name + "." + String(key)) : v
}
if (key === "then") return undefined
return stub(name + "." + String(key))
},
})
return wrap(known, "client")
}
// ---- loading plugins ---------------------------------------------------------
const INDEX_FILES = ["index.ts", "index.tsx", "index.js", "index.mjs", "index.cjs"]
function readJSON(file) {
try {
return JSON.parse(fs.readFileSync(file, "utf8"))
} catch {
return undefined
}
}
// entries are the files a plugin's server side may load from, as
// OpenCode looks for them: the package's exports["./server"], its main,
// exports["."], else an index file; a path to a file is that file. A
// package whose ./server is written for OpenCode's next plugin API
// (Plugin.define, opencode-gemini-auth 2) keeps its v1 plugin at main.
function entries(target) {
const stat = fs.statSync(target, { throwIfNoEntry: false })
if (!stat) throw new Error(`no such plugin: ${target}`)
if (!stat.isDirectory()) return [target]
const out = []
const add = (v) => typeof v === "string" && v.trim() && out.push(path.resolve(target, v.trim()))
const pick = (x) => (typeof x === "string" ? x : x?.import ?? x?.default)
const pkg = readJSON(path.join(target, "package.json"))
if (pkg) {
const ex = pkg.exports && typeof pkg.exports === "object" && !Array.isArray(pkg.exports) ? pkg.exports : undefined
if (ex) add(pick(ex["./server"]))
add(pkg.main)
if (ex) add(pick(ex["."]))
if (typeof pkg.exports === "string") add(pkg.exports)
}
for (const f of INDEX_FILES) {
const p = path.join(target, f)
if (fs.existsSync(p)) out.push(p)
}
if (out.length === 0) throw new Error(`plugin ${target} has no entry (package.json main, exports or index file)`)
return [...new Set(out)]
}
// servers are the plugin functions a module exports: default {id?,
// server} (v1), else every function it exports, or {server} objects
// (legacy), each once.
function servers(mod) {
const d = mod.default
if (d && typeof d === "object" && ("server" in d || "id" in d || "tui" in d)) {
return typeof d.server === "function" ? [d.server] : []
}
const seen = new Set()
const out = []
for (const v of Object.values(mod)) {
if (seen.has(v)) continue
seen.add(v)
if (typeof v === "function") out.push(v)
else if (v && typeof v === "object" && typeof v.server === "function") out.push(v.server)
}
return out
}
async function loadPlugins(list) {
const input = {
client: makeClient(),
project: { id: "magpie", worktree: directory, vcs: undefined, time: { created: Date.now() } },
directory,
worktree: directory,
experimental_workspace: { register() {} },
serverUrl: new URL("http://127.0.0.1:4096"),
$: Bun.$,
}
for (const p of list) {
try {
let fns = []
for (const file of entries(p.target)) {
fns = servers(await import(pathToFileURL(file).href))
if (fns.length) break
}
if (fns.length === 0) throw new Error("exports no OpenCode plugin function")
for (const fn of fns) hooks.push({ spec: p.spec, hooks: (await fn(input, p.options)) ?? {} })
loaded.push({ spec: p.spec })
} catch (e) {
loaded.push({ spec: p.spec, error: String(e?.stack ?? e) })
}
}
config = { provider: {}, ...structuredClone(userConfig) }
for (const h of hooks) {
if (typeof h.hooks.config !== "function") continue
try {
await h.hooks.config(config)
} catch (e) {
send({ event: "log", level: "error", message: `${h.spec}: config hook: ${e?.message ?? e}` })
}
}
}
// auths are the auth hooks by provider, the last plugin to name one
// winning, as in OpenCode.
function auths() {
const m = new Map()
for (const h of hooks) if (h.hooks.auth?.provider) m.set(h.hooks.auth.provider, { spec: h.spec, auth: h.hooks.auth })
return m
}
function authOf(provider) {
const a = auths().get(provider)
if (!a) throw new Error(`no plugin signs in to ${provider}`)
return a.auth
}
// ---- providers and models ----------------------------------------------------
let modelsDev
function mdev() {
if (modelsDev === undefined) modelsDev = (modelsDevPath && readJSON(modelsDevPath)) || {}
return modelsDev
}
function cost(c) {
return {
input: c?.input ?? 0,
output: c?.output ?? 0,
cache: { read: c?.cache_read ?? c?.cache?.read ?? 0, write: c?.cache_write ?? c?.cache?.write ?? 0 },
}
}
// fromModelsDev is a models.dev model as OpenCode's provider list has it.
function fromModelsDev(p, m) {
return {
id: m.id,
providerID: p.id,
name: m.name ?? m.id,
family: m.family,
api: { id: m.id, url: m.provider?.api ?? p.api ?? "", npm: m.provider?.npm ?? p.npm ?? "@ai-sdk/openai-compatible" },
status: m.status ?? "active",
headers: {},
options: {},
cost: cost(m.cost),
limit: { context: m.limit?.context ?? 0, input: m.limit?.input, output: m.limit?.output ?? 0 },
capabilities: {
temperature: m.temperature ?? false,
reasoning: m.reasoning ?? false,
attachment: m.attachment ?? false,
toolcall: m.tool_call ?? true,
input: {
text: true,
image: (m.modalities?.input ?? []).includes("image"),
audio: (m.modalities?.input ?? []).includes("audio"),
video: (m.modalities?.input ?? []).includes("video"),
pdf: (m.modalities?.input ?? []).includes("pdf"),
},
output: { text: true, image: false, audio: false, video: false, pdf: false },
interleaved: m.interleaved ?? false,
},
release_date: m.release_date ?? "",
variants: {},
}
}
// info is the provider as OpenCode builds it: models.dev's entry, what
// the config (with the plugins' config hooks) says of it, the plugin's
// provider.models hook.
async function info(id) {
const md = mdev()[id]
const cfg = config.provider?.[id]
const out = {
id,
name: cfg?.name ?? md?.name ?? id,
source: md ? "api" : "config",
env: cfg?.env ?? md?.env ?? [],
options: { ...(cfg?.options ?? {}) },
npm: cfg?.npm ?? md?.npm,
api: cfg?.api ?? md?.api,
models: {},
}
if (md) for (const m of Object.values(md.models ?? {})) out.models[m.id] = fromModelsDev(md, m)
for (const [key, m] of Object.entries(cfg?.models ?? {})) {
const was = out.models[m.id ?? key]
const npm = m.provider?.npm ?? cfg.npm ?? was?.api.npm ?? md?.npm ?? "@ai-sdk/openai-compatible"
out.models[key] = {
...(was ?? {}),
id: key,
providerID: id,
name: m.name ?? was?.name ?? key,
api: { id: m.id ?? was?.api.id ?? key, url: m.provider?.api ?? cfg.api ?? was?.api.url ?? md?.api ?? "", npm },
cost: m.cost ? cost(m.cost) : was?.cost ?? cost(),
limit: { ...(was?.limit ?? { context: 0, output: 0 }), ...(m.limit ?? {}) },
options: { ...(was?.options ?? {}), ...(m.options ?? {}) },
headers: { ...(was?.headers ?? {}), ...(m.headers ?? {}) },
capabilities: {
...(was?.capabilities ?? { temperature: true, toolcall: true, input: { text: true }, output: { text: true } }),
...(m.reasoning !== undefined ? { reasoning: m.reasoning } : {}),
...(m.attachment !== undefined ? { attachment: m.attachment } : {}),
...(m.tool_call !== undefined ? { toolcall: m.tool_call } : {}),
...(m.modalities?.input ? { input: Object.fromEntries(["text", "image", "audio", "video", "pdf"].map((k) => [k, m.modalities.input.includes(k)])) } : {}),
},
variants: m.variants ?? was?.variants ?? {},
}
}
for (const h of hooks) {
const ph = h.hooks.provider
if (ph?.id !== id || typeof ph.models !== "function") continue
try {
const next = await ph.models(JSON.parse(JSON.stringify(out)), { auth: readAuth()[id] })
out.models = Object.fromEntries(Object.entries(next ?? {}).map(([k, m]) => [k, { ...m, id: k, providerID: id }]))
} catch (e) {
send({ event: "log", level: "error", message: `${h.spec}: provider.models: ${e?.message ?? e}` })
}
}
for (const [k, m] of Object.entries(cfg?.models ?? {})) if (m?.disabled) delete out.models[k]
return out
}
function methods(auth) {
return (auth.methods ?? []).map((m) => ({ type: m.type, label: m.label }))
}
async function providers() {
const stored = readAuth()
const out = []
for (const [id, a] of auths()) {
const p = await info(id)
out.push({
id,
spec: a.spec,
name: p.name,
npm: p.npm ?? "",
api: p.api ?? "",
methods: methods(a.auth),
signedIn: !!stored[id],
authType: stored[id]?.type ?? "",
accountId: stored[id]?.accountId ?? stored[id]?.metadata?.email ?? stored[id]?.email ?? "",
models: Object.values(p.models)
.filter((m) => m.status !== "deprecated")
.map((m) => ({
id: m.id,
name: m.name,
npm: m.api?.npm ?? p.npm ?? "",
url: m.api?.url ?? "",
apiId: m.api?.id ?? m.id,
context: m.limit?.context ?? 0,
input: m.limit?.input ?? 0,
output: m.limit?.output ?? 0,
reasoning: !!m.capabilities?.reasoning,
image: !!m.capabilities?.input?.image,
released: m.release_date ?? "",
cost: m.cost,
variants: Object.keys(m.variants ?? {}),
})),
})
}
return out
}
// ---- signing in --------------------------------------------------------------
function applies(prompt, inputs) {
if (prompt.when) {
const v = inputs[prompt.when.key]
if (v === undefined) return false
const eq = v === prompt.when.value
if (prompt.when.op === "eq" ? !eq : eq) return false
}
if (typeof prompt.condition === "function" && !prompt.condition(inputs)) return false
return true
}
// nextPrompt is the method's next question for inputs so far, as
// OpenCode's CLI asks them: in order, those whose when/condition hold.
function nextPrompt(provider, index, inputs) {
const m = authOf(provider).methods[index]
if (!m) throw new Error(`no sign-in method ${index} for ${provider}`)
for (const p of m.prompts ?? []) {
if (p.key in inputs) continue
if (!applies(p, inputs)) continue
return {
type: p.type,
key: p.key,
message: p.message,
placeholder: p.placeholder ?? "",
options: p.type === "select" ? p.options.map((o) => ({ label: o.label, value: o.value, hint: o.hint ?? "" })) : undefined,
}
}
return null
}
function validate(provider, index, key, value) {
const p = (authOf(provider).methods[index]?.prompts ?? []).find((x) => x.key === key)
if (!p || typeof p.validate !== "function") return null
return p.validate(value) ?? null
}
// save keeps a successful sign-in as OpenCode's CLI does.
function save(provider, result, inputs, apiKey) {
const id = result.provider ?? provider
if ("refresh" in result) {
const { type: _t, provider: _p, refresh, access, expires, ...extra } = result
setAuth(id, { type: "oauth", refresh, access, expires, ...extra })
} else if ("key" in result || apiKey) {
const md = { ...(inputs && Object.keys(inputs).length ? inputs : {}), ...(result.metadata ?? {}) }
setAuth(id, { type: "api", key: result.key ?? apiKey, ...(Object.keys(md).length ? { metadata: md } : {}) })
}
return id
}
let nextSession = 1
async function authorize({ provider, method, inputs }) {
const m = authOf(provider).methods[method]
if (!m) throw new Error(`no sign-in method ${method} for ${provider}`)
if (m.type !== "oauth") throw new Error("not an oauth method")
const a = await m.authorize(inputs ?? {})
const session = String(nextSession++)
sessions.set(session, { provider, a, inputs })
return { session, url: a.url ?? "", instructions: a.instructions ?? "", method: a.method }
}
async function callback({ session, code }) {
const s = sessions.get(session)
if (!s) throw new Error("no such sign-in")
sessions.delete(session)
const r = s.a.method === "code" ? await s.a.callback(code ?? "") : await s.a.callback()
if (!r || r.type !== "success") return { ok: false }
return { ok: true, provider: save(s.provider, r, undefined) }
}
async function apiKey({ provider, method, inputs, key }) {
const m = authOf(provider).methods[method]
if (!m || m.type !== "api") throw new Error("not an API key method")
if (typeof m.authorize !== "function") {
const md = inputs && Object.keys(inputs).length ? { metadata: inputs } : {}
setAuth(provider, { type: "api", key, ...md })
return { ok: true, provider }
}
const r = await m.authorize(inputs ?? {})
if (!r || r.type !== "success") return { ok: false }
return { ok: true, provider: save(provider, r, inputs, key) }
}
// ---- requests ----------------------------------------------------------------
// options is what the provider's auth loader returned, run once per
// sign-in as OpenCode runs it once per start.
async function options(provider) {
if (loaders.has(provider)) return loaders.get(provider)
const a = auths().get(provider)?.auth
const stored = readAuth()[provider]
let opts = {}
if (a?.loader && stored) {
const p = await info(provider)
opts = (await a.loader(async () => readAuth()[provider], JSON.parse(JSON.stringify(p)))) ?? {}
}
if (!opts.apiKey && stored?.type === "api") opts = { ...opts, apiKey: stored.key }
loaders.set(provider, opts)
return opts
}
async function load({ provider }) {
const o = await options(provider)
return {
baseURL: typeof o.baseURL === "string" ? o.baseURL : "",
apiKey: typeof o.apiKey === "string" ? o.apiKey : "",
headers: o.headers && typeof o.headers === "object" ? o.headers : {},
fetch: typeof o.fetch === "function",
}
}
// sdkHeaders are what the AI SDK package the model is on sends of the key.
function sdkHeaders(npm, key) {
if (!key) return {}
if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") return { "x-api-key": key }
if (npm === "@ai-sdk/google") return { "x-goog-api-key": key }
return { authorization: `Bearer ${key}` }
}
async function doFetch(id, { provider, model, npm, url, method, headers, body, session }) {
const ctl = new AbortController()
inflight.set(id, ctl)
try {
const o = await options(provider)
const h = new Headers()
for (const [k, v] of Object.entries(sdkHeaders(npm, o.apiKey))) h.set(k, v)
for (const [k, v] of Object.entries(o.headers ?? {})) h.set(k, String(v))
for (const [k, v] of Object.entries(headers ?? {})) h.set(k, v)
// chat.headers: what the plugins add to the request, theirs winning
const p = await info(provider)
const m = p.models[model] ?? { id: model, providerID: provider, api: { id: model, npm } }
for (const x of hooks) {
const fn = x.hooks["chat.headers"]
if (typeof fn !== "function") continue
const out = { headers: {} }
try {
await fn(
{
sessionID: session ?? "",
agent: "build",
model: m,
provider: { source: "custom", info: p, options: o },
message: { id: "", sessionID: session ?? "", role: "user", time: { created: Date.now() }, agent: "build", model: { providerID: provider, modelID: model } },
},
out,
)
} catch (e) {
send({ event: "log", level: "error", message: `${x.spec}: chat.headers: ${e?.message ?? e}` })
}
for (const [k, v] of Object.entries(out.headers)) h.set(k, String(v))
}
const f = typeof o.fetch === "function" ? o.fetch : fetch
const res = await f(url, {
method: method ?? "POST",
headers: h,
body: body ? Buffer.from(body, "base64") : undefined,
signal: ctl.signal,
})
const rh = {}
res.headers.forEach((v, k) => (rh[k] = v))
send({ id, event: "head", status: res.status, headers: rh })
if (res.body) {
for await (const chunk of res.body) {
send({ id, event: "chunk", data: Buffer.from(chunk).toString("base64") })
}
}
send({ id, result: null })
} catch (e) {
send({ id, error: { message: String(e?.message ?? e) } })
} finally {
inflight.delete(id)
}
}
// ---- the loop ----------------------------------------------------------------
const handlers = {
async init(p) {
authPath = p.authPath
modelsDevPath = p.modelsDevPath ?? ""
directory = p.directory ?? directory
userConfig = p.config ?? {}
await loadPlugins(p.plugins ?? [])
return { plugins: loaded }
},
providers: () => providers(),
prompt: (p) => ({ prompt: nextPrompt(p.provider, p.method, p.inputs ?? {}) }),
validate: (p) => ({ error: validate(p.provider, p.method, p.key, p.value) }),
authorize,
callback,
apiKey,
load,
signOut(p) {
removeAuth(p.provider)
return null
},
reload(p) {
loaders.delete(p.provider)
return null
},
}
// Everything waits for init; a request is answered even when magpie has
// closed stdin behind it, the host leaving once nothing is pending.
let ready
let pending = 0
let closed = false
const done = () => {
if (--pending === 0 && closed) process.exit(0)
}
const rl = readline.createInterface({ input: process.stdin, terminal: false })
rl.on("line", (line) => {
if (!line.trim()) return
let msg
try {
msg = JSON.parse(line)
} catch {
return
}
if (msg.method === "abort") {
inflight.get(msg.params?.id)?.abort()
return
}
pending++
if (msg.method === "init") {
ready = handlers.init(msg.params ?? {})
ready.then(
(result) => send({ id: msg.id, result }),
(e) => send({ id: msg.id, error: { message: String(e?.message ?? e) } }),
).finally(done)
return
}
const wait = ready ?? Promise.reject(new Error("the host has not been initialised"))
if (msg.method === "fetch") {
wait.then(() => doFetch(msg.id, msg.params ?? {}), (e) => send({ id: msg.id, error: { message: String(e?.message ?? e) } })).finally(done)
return
}
const fn = handlers[msg.method]
if (!fn) {
send({ id: msg.id, error: { message: `no method ${msg.method}` } })
done()
return
}
wait
.then(() => fn(msg.params ?? {}))
.then(
(result) => send({ id: msg.id, result: result ?? null }),
(e) => send({ id: msg.id, error: { message: String(e?.message ?? e) } }),
)
.finally(done)
})
rl.on("close", () => {
closed = true
if (pending === 0) process.exit(0)
})