diff --git a/README.md b/README.md index 9c5971c9..9d34762e 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,39 @@ magpie plugin off opencode-gemini-auth # on brings it back; rm removes it; upda A provider id magpie already has (google, openai, anthropic) is `-plugin`. In the app, Settings → Plugins adds and removes them, and the providers they sign in to are in Add provider → From plugins. + +#### For plugin authors + +A plugin is an OpenCode plugin; magpie reads a few more fields, which +OpenCode ignores: + +- **The provider's icon**: `icon` on the `auth` hook, or `"magpie": { + "icon": "…" }` in the plugin's `package.json` (for every provider it + signs in to that names none). An `https://` URL of a picture on a public + host, which magpie fetches once and keeps, or a `data:image/…` URI; + PNG, JPEG, GIF, WebP, ICO or SVG, at most 1 MB. Anything else is ignored, + and the icon the plugin market lists for the plugin is shown instead. +- **An API key's field**: a `type: "api"` method's `label` titles the key's + field, as OpenCode's dialog does (one that only says "API key" reads + " API key"), and its `placeholder` is the hint inside the field + (and after the question in `magpie plugin login`). The method's `prompts` + are asked first, as in OpenCode, and reach `authorize(inputs)`. + +```js +export const LemonPlugin = async () => ({ + auth: { + provider: "lemon", + icon: "https://lemon.example/icon.png", // or "data:image/svg+xml;base64,…" + methods: [ + { type: "api", label: "Lemon API key (lemon.example/keys)", placeholder: "sk-lemon-…" }, + ], + }, +}) +``` + +In TypeScript, `icon` and `placeholder` aren't in OpenCode's types: build +the hook as a variable (or cast it), or put the icon in `package.json`. + ### What a model costs A call is counted at its **effective price**: what you set for that provider diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 35854326..c1920c64 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -5162,9 +5162,14 @@ function renderPluginAsk(sub) { return box; } const key = flow.state === "key"; - const label = key ? t("{name} API key", { name: sub.name }) : q.message; + // the key's field is titled by the way's label, as OpenCode's dialog + // is, unless that only says "API key"; the plugin's placeholder hints + // at what the key looks like + const way = key ? sub.plugin.methods?.[flow.method] || {} : {}; + const own = (way.label || "").trim(); + const label = key ? (own && own.toLowerCase() !== "api key" ? own : t("{name} API key", { name: sub.name })) : q.message; tt.append(el("span", "n", label)); - const inp = input(flow.value || "", key ? "" : (q.placeholder || ""), key ? "password" : "text"); + const inp = input(flow.value || "", key ? (way.placeholder || "") : (q.placeholder || ""), key ? "password" : "text"); inp.setAttribute("aria-label", label); inp.autocomplete = "off"; inp.disabled = !!flow.busy; diff --git a/internal/gui/plugins.go b/internal/gui/plugins.go index 1c16bcfd..12ccb4d8 100644 --- a/internal/gui/plugins.go +++ b/internal/gui/plugins.go @@ -27,14 +27,14 @@ type pluginSubJSON struct { Models int `json:"models"` } -// pluginIcon is the vendor's icon: the one the plugin market gives the -// plugin or its provider, else that of the providers OpenCode names, a -// plain one for the rest. -func pluginIcon(spec, id string) string { - if ic := plugin.Icon(spec, id); ic != "" { +// pluginIcon is the vendor's icon: the one the plugin gives its provider, +// else the one the plugin market gives the plugin or its provider, else +// that of the providers OpenCode names, a plain one for the rest. +func pluginIcon(pp plugin.Provider) string { + if ic := provider.PluginIcon(pp); ic != "" { return ic } - switch id { + switch pp.ID { case "github-copilot", "github-copilot-enterprise": return "githubcopilot" case "anthropic": @@ -55,7 +55,7 @@ func pluginSubs() []pluginSubJSON { out := []pluginSubJSON{} for _, pp := range plugin.Cached() { out = append(out, pluginSubJSON{ - ID: provider.PluginID(pp.ID), PID: pp.ID, Name: pp.Name, Icon: pluginIcon(pp.Spec, pp.ID), Spec: pp.Spec, + ID: provider.PluginID(pp.ID), PID: pp.ID, Name: pp.Name, Icon: pluginIcon(pp), Spec: pp.Spec, Methods: pp.Methods, SignedIn: pp.SignedIn, Models: len(pp.Models), }) } diff --git a/internal/gui/providers.go b/internal/gui/providers.go index 536efd51..4616a784 100644 --- a/internal/gui/providers.go +++ b/internal/gui/providers.go @@ -298,7 +298,7 @@ func providerInfo(p provider.Provider, agents []agentUse) providerJSON { if pp, ok := provider.PluginOf(p.ID); ok && p.IsPlugin() { // a plugin's sign-in: named for the provider it signs in to, // the page following it by the provider's id - out.Account.Agent, out.Account.Name, out.Account.Icon = p.ID, pp.Name, pluginIcon(pp.Spec, pp.ID) + out.Account.Agent, out.Account.Name, out.Account.Icon = p.ID, pp.Name, pluginIcon(pp) if provider.Moved(pp.ID) { out.Account.Icon = p.Icon // the built-in's, as it was } diff --git a/internal/gui/tests/README.md b/internal/gui/tests/README.md index d4a13092..40fecc48 100644 --- a/internal/gui/tests/README.md +++ b/internal/gui/tests/README.md @@ -33,6 +33,11 @@ method's questions (a pick, then a text the plugin checks), then takes the code the browser page shows; an API-key way opens the account. English and Chinese, Chromium and WebKit, with the API faked. +`plugin-key-hint.test.cjs` checks a plugin's API-key way: its label titles +the key's field and its placeholder is the field's hint, while a way labelled +only "API key" keeps " API key"; the provider's own icon shows on its +row. English and Chinese, Chromium and WebKit, with the API faked. + `plugin-market.test.cjs` checks the Plugins tab: Discover lists the suggested plugins in their two sections, a card installs its plugin and then offers its sign-in, which opens in the Providers add sheet; a search @@ -588,7 +593,7 @@ with `/api/plugins` faked. With Node.js and Playwright available: ```sh -node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/rollup-short.test.cjs internal/gui/tests/panel-routing.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs internal/gui/tests/usage-ledger.test.cjs internal/gui/tests/update-check.test.cjs internal/gui/tests/omarchy.test.cjs internal/gui/tests/brand.test.cjs internal/gui/tests/library-loading.test.cjs node --test internal/gui/tests/signin-callback.test.cjs internal/gui/tests/routing-steady.test.cjs internal/gui/tests/old-webkit.test.cjs internal/gui/tests/session-terminal.test.cjs internal/gui/tests/text-size.test.cjs internal/gui/tests/zcode-site.test.cjs internal/gui/tests/rail-tip.test.cjs internal/gui/tests/sync-update.test.cjs internal/gui/tests/plugin-signin.test.cjs internal/gui/tests/plugin-market.test.cjs internal/gui/tests/s3-sync.test.cjs internal/gui/tests/routing-wb-refused.test.cjs internal/gui/tests/routing-shape.test.cjs internal/gui/tests/market-have.test.cjs internal/gui/tests/sessions-today.test.cjs internal/gui/tests/routing-side-calls.test.cjs internal/gui/tests/import-all-skills.test.cjs internal/gui/tests/routing-flood.test.cjs internal/gui/tests/routing-busy.test.cjs internal/gui/tests/own-skill.test.cjs internal/gui/tests/routing-manual.test.cjs internal/gui/tests/tray-usages.test.cjs internal/gui/tests/provider-remove.test.cjs internal/gui/tests/signin-link.test.cjs internal/gui/tests/tray-inuse.test.cjs internal/gui/tests/codex-auto-reset.test.cjs internal/gui/tests/routing-auto-reset.test.cjs internal/gui/tests/claude-resets.test.cjs internal/gui/tests/library-toggle-status.test.cjs internal/gui/tests/library-all.test.cjs internal/gui/tests/routing-reroute-title.test.cjs internal/gui/tests/plain-names.test.cjs internal/gui/tests/panel-effort.test.cjs internal/gui/tests/agent-models-all-hidden.test.cjs internal/gui/tests/omp-roles.test.cjs internal/gui/tests/claude-effort.test.cjs internal/gui/tests/remote-magpie.test.cjs internal/gui/tests/qoder-cn-tile.test.cjs internal/gui/tests/provider-levels-scroll.test.cjs internal/gui/tests/signin-paste-codex.test.cjs internal/gui/tests/usage-alert.test.cjs internal/gui/tests/quota-families.test.cjs internal/gui/tests/provider-search.test.cjs internal/gui/tests/webdav-settings-refresh.test.cjs internal/gui/tests/lan-docker.test.cjs +node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/rollup-short.test.cjs internal/gui/tests/panel-routing.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs internal/gui/tests/usage-ledger.test.cjs internal/gui/tests/update-check.test.cjs internal/gui/tests/omarchy.test.cjs internal/gui/tests/brand.test.cjs internal/gui/tests/library-loading.test.cjs node --test internal/gui/tests/signin-callback.test.cjs internal/gui/tests/routing-steady.test.cjs internal/gui/tests/old-webkit.test.cjs internal/gui/tests/session-terminal.test.cjs internal/gui/tests/text-size.test.cjs internal/gui/tests/zcode-site.test.cjs internal/gui/tests/rail-tip.test.cjs internal/gui/tests/sync-update.test.cjs internal/gui/tests/plugin-signin.test.cjs internal/gui/tests/plugin-market.test.cjs internal/gui/tests/s3-sync.test.cjs internal/gui/tests/routing-wb-refused.test.cjs internal/gui/tests/routing-shape.test.cjs internal/gui/tests/market-have.test.cjs internal/gui/tests/sessions-today.test.cjs internal/gui/tests/routing-side-calls.test.cjs internal/gui/tests/import-all-skills.test.cjs internal/gui/tests/routing-flood.test.cjs internal/gui/tests/routing-busy.test.cjs internal/gui/tests/own-skill.test.cjs internal/gui/tests/routing-manual.test.cjs internal/gui/tests/tray-usages.test.cjs internal/gui/tests/provider-remove.test.cjs internal/gui/tests/signin-link.test.cjs internal/gui/tests/tray-inuse.test.cjs internal/gui/tests/codex-auto-reset.test.cjs internal/gui/tests/routing-auto-reset.test.cjs internal/gui/tests/claude-resets.test.cjs internal/gui/tests/library-toggle-status.test.cjs internal/gui/tests/library-all.test.cjs internal/gui/tests/routing-reroute-title.test.cjs internal/gui/tests/plain-names.test.cjs internal/gui/tests/panel-effort.test.cjs internal/gui/tests/agent-models-all-hidden.test.cjs internal/gui/tests/omp-roles.test.cjs internal/gui/tests/claude-effort.test.cjs internal/gui/tests/remote-magpie.test.cjs internal/gui/tests/qoder-cn-tile.test.cjs internal/gui/tests/provider-levels-scroll.test.cjs internal/gui/tests/signin-paste-codex.test.cjs internal/gui/tests/usage-alert.test.cjs internal/gui/tests/quota-families.test.cjs internal/gui/tests/provider-search.test.cjs internal/gui/tests/webdav-settings-refresh.test.cjs internal/gui/tests/lan-docker.test.cjs internal/gui/tests/plugin-key-hint.test.cjs ``` If Playwright is installed outside the repository, set `NODE_PATH` to the diff --git a/internal/gui/tests/plugin-key-hint.test.cjs b/internal/gui/tests/plugin-key-hint.test.cjs new file mode 100644 index 00000000..ba94177c --- /dev/null +++ b/internal/gui/tests/plugin-key-hint.test.cjs @@ -0,0 +1,88 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// A plugin's "api" way to sign in titles its key's field with its label, +// as OpenCode's dialog does, and hints at the key with its placeholder +// (Lemon on Discord); a way labelled only "API key" keeps " API +// key". The provider's own icon (a picture magpie kept) shows on its row. +// English and Chinese; the API is faked here. +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { test } = require("node:test"); +const { chromium, webkit } = require("playwright"); + +const assets = path.resolve(__dirname, "../assets"); + +const plugin = { id: "lemon", pid: "lemon", name: "Lemon", icon: "file:0123456789abcdef.svg", spec: "opencode-lemon-auth", signedIn: false, models: 2, + methods: [{ type: "api", label: "Lemon API key (from lemon.example/keys)", placeholder: "sk-lemon-…" }, { type: "api", label: "API key" }] }; + +function server(lang, asked) { + return async (route) => { + const url = new URL(route.request().url()); + const json = (data) => route.fulfill({ json: data }); + if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:false};` }); + if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" }); + if (url.pathname === "/api/state") return json({ agents: [], profiles: [], settings: { lang, theme: "light" } }); + if (url.pathname === "/api/providers") return json({ providers: [{ id: "openai", name: "OpenAI", icon: "openai", preset: "openai", models: [], agents: [], key: { set: true, masked: "sk-…ab12" } }], presets: [], excluded: [], gateway: { running: true, window: true }, plugins: [plugin] }); + if (url.pathname === "/api/plugins") return json({ plugins: [] }); + if (url.pathname === "/api/groups") return json({ groups: [], pools: [] }); + if (url.pathname === "/api/gateway/trace") return json({ routes: [] }); + if (url.pathname === "/api/icons/0123456789abcdef.svg") return route.fulfill({ contentType: "image/svg+xml", body: '' }); + if (url.pathname === "/api/plugin-signin/prompt") { asked.push(["prompt", route.request().postDataJSON()]); return json({ prompt: null, inputs: {} }); } + if (url.pathname.startsWith("/api/")) return json({}); + const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname); + const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)]; + try { await route.fulfill({ body: await fs.readFile(file), contentType }); } catch { await route.fulfill({ status: 404, body: "" }); } + }; +} + +const L = { + en: { section: "From plugins", how: "How do you sign in to Lemon?", generic: "Lemon API key" }, + zh: { section: "来自插件", how: "用哪种方式登录 Lemon?", generic: "Lemon API Key" }, +}; + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + test(engine + ": a plugin's key field", async (t) => { + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + t.after(() => browser.close()); + for (const lang of ["en", "zh"]) { + await t.test(lang, async () => { + const w = L[lang]; + const page = await (await browser.newContext({ viewport: { width: 900, height: 800 } })).newPage(); + page.setDefaultTimeout(5000); + const errors = [], asked = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/*", server(lang, asked)); + await page.goto("http://magpie.test/?view=providers"); + await page.locator("#addProvider").click(); + const sheet = page.locator("#addSheet"); + await sheet.locator(".kind b", { hasText: w.section }).waitFor(); + const row = sheet.locator('.tile[data-pick="Lemon"]'); + // the plugin's own picture + const img = row.locator('.ic img[src="/api/icons/0123456789abcdef.svg"]'); + await img.waitFor(); + await page.waitForFunction((e) => e.complete && e.naturalWidth > 0, await img.elementHandle()); + + await row.click(); + const box = sheet.locator(".signing"); + await box.locator(".n", { hasText: w.how }).waitFor(); + await box.locator("button", { hasText: "Lemon API key (from lemon.example/keys)" }).click(); + const key = box.getByLabel("Lemon API key (from lemon.example/keys)", { exact: true }); + await key.waitFor(); + assert.equal(await box.locator(".n").first().innerText(), "Lemon API key (from lemon.example/keys)"); + assert.equal(await key.getAttribute("placeholder"), "sk-lemon-…"); + assert.equal(await key.getAttribute("type"), "password"); + if (process.env.ARTIFACT_DIR) await box.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `plugin-key-hint-${engine}-${lang}.png`) }); + + // a way that only says "API key": the provider's name, no hint + await box.locator("button.text:not(.primary)").last().click(); + await row.click(); + await box.locator("button", { hasText: /^API key$/ }).click(); + const plain = box.getByLabel(w.generic, { exact: true }); + await plain.waitFor(); + assert.equal(await plain.getAttribute("placeholder") ?? "", ""); + + assert.deepEqual(errors, []); + }); + } + }); +} diff --git a/internal/plugin/host.js b/internal/plugin/host.js index 0f93f925..17ad098d 100644 --- a/internal/plugin/host.js +++ b/internal/plugin/host.js @@ -379,7 +379,7 @@ async function loadPlugins(list) { 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)) ?? {} }) + for (const fn of fns) hooks.push({ spec: p.spec, target: p.target, hooks: (await fn(input, p.options)) ?? {} }) loaded.push({ spec: p.spec }) } catch (e) { loaded.push({ spec: p.spec, error: String(e?.stack ?? e) }) @@ -400,7 +400,7 @@ async function loadPlugins(list) { // 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 }) + for (const h of hooks) if (h.hooks.auth?.provider) m.set(h.hooks.auth.provider, { spec: h.spec, target: h.target, auth: h.hooks.auth }) return m } @@ -528,8 +528,35 @@ async function info(id, key, strict) { return out } +// shown is a plugin's string as magpie shows it: trimmed and at most n +// characters, "" for anything else. +const shown = (v, n) => (typeof v === "string" ? v.trim().slice(0, n) : "") + +// methods are the auth hook's ways to sign in. An "api" one may say what +// its key looks like (placeholder, magpie's own: OpenCode's dialog says +// "API key"); its label titles the key's field, as OpenCode's does. function methods(auth) { - return (auth.methods ?? []).map((m) => ({ type: m.type, label: m.label })) + return (auth.methods ?? []).map((m) => ({ + type: m.type, + label: m.label, + ...(m.type === "api" && shown(m.placeholder, 200) ? { placeholder: shown(m.placeholder, 200) } : {}), + })) +} + +// iconOf is the picture a plugin gives its provider, magpie's own field +// (OpenCode's providers have none): the auth hook's icon, else +// package.json's magpie.icon. An https URL or a data:image URI; magpie +// checks and keeps it (internal/provider), nothing is fetched here. +function iconOf(a) { + const ok = (v) => { + const s = typeof v === "string" ? v.trim() : "" + // a data URI of a picture over 1 MB, magpie's most, isn't carried + return s.length <= 3 << 19 && /^(https:\/\/|data:image\/)/i.test(s) ? s : "" + } + const own = ok(a.auth.icon) + if (own || !a.target) return own + const dir = fs.statSync(a.target, { throwIfNoEntry: false })?.isDirectory() ? a.target : path.dirname(a.target) + return ok(readJSON(path.join(dir, "package.json"))?.magpie?.icon) } async function providers() { @@ -553,6 +580,7 @@ async function providers() { npm: p.npm ?? "", api: p.api ?? "", methods: methods(a.auth), + icon: iconOf(a), usage: typeof a.auth.usage === "function", signedIn: keys.length > 0, authType: first?.type ?? "", diff --git a/internal/plugin/plugin_icon_test.go b/internal/plugin/plugin_icon_test.go new file mode 100644 index 00000000..a18ccef8 --- /dev/null +++ b/internal/plugin/plugin_icon_test.go @@ -0,0 +1,60 @@ +package plugin + +import ( + "context" + "path/filepath" + "strings" + "testing" + "time" +) + +// Lemon on Discord: can a plugin give its provider its own icon, and say +// what its API key is asked as? The auth hook's icon, else package.json's +// magpie.icon, is passed on when it is an https URL or a data:image URI; +// an "api" method's placeholder is its key's hint, and its label the +// key's title unless that only says "API key". +func TestPluginOwnIconAndKeyHint(t *testing.T) { + sandbox(t) + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + abs, _ := filepath.Abs("testdata/ownicon") + if _, err := Add(ctx, abs); err != nil { + t.Fatal(err) + } + ps, err := Providers(ctx) + if err != nil { + t.Fatal(err) + } + by := map[string]Provider{} + for _, p := range ps { + by[p.ID] = p + } + lemon, lime := by["lemon"], by["lime"] + if !strings.HasPrefix(lemon.Icon, "data:image/svg+xml;base64,") { + t.Errorf("lemon's icon = %.60q", lemon.Icon) + } + if lime.Icon != "https://ownicon.example/icon.png" { + t.Errorf("lime's icon = %q, want package.json's", lime.Icon) + } + if len(lemon.Methods) != 2 || lemon.Methods[0] != (Method{Type: "api", Label: "Lemon API key (from lemon.example/keys)", Placeholder: "sk-lemon-…"}) || lemon.Methods[1].Placeholder != "" { + t.Fatalf("lemon's methods = %+v", lemon.Methods) + } + if len(lime.Methods) != 1 || lime.Methods[0].Placeholder != "" { + t.Fatalf("lime's methods = %+v", lime.Methods) + } + if got := lemon.Methods[0].KeyTitle("Lemon"); got != "Lemon API key (from lemon.example/keys)" { + t.Errorf("lemon's key title = %q", got) + } + if got := lime.Methods[0].KeyTitle("Lime"); got != "Lime API key" { + t.Errorf("lime's key title = %q", got) + } + if got := (Method{Type: "api"}).KeyTitle("Lime"); got != "Lime API key" { + t.Errorf("no label's key title = %q", got) + } + // kept for when magpie has only just started + for _, p := range Cached() { + if p.ID == "lemon" && p.Icon != lemon.Icon { + t.Errorf("cached icon = %.60q", p.Icon) + } + } +} diff --git a/internal/plugin/providers.go b/internal/plugin/providers.go index 6ba6ac74..e0be826c 100644 --- a/internal/plugin/providers.go +++ b/internal/plugin/providers.go @@ -20,6 +20,20 @@ import ( type Method struct { Type string `json:"type"` Label string `json:"label"` + // Placeholder is the hint an "api" method gives in its key's field + // (magpie's own field: OpenCode's says "API key") + Placeholder string `json:"placeholder,omitempty"` +} + +// KeyTitle is what an "api" method's key is asked as: its label, as +// OpenCode's dialog titles it, unless that only says "API key" (or +// nothing), when it is name's API key. +func (m Method) KeyTitle(name string) string { + l := strings.TrimSpace(m.Label) + if l == "" || strings.EqualFold(l, "API key") { + return name + " API key" + } + return l } // Model is a model a plugin's provider serves, as OpenCode lists it. @@ -56,6 +70,9 @@ type Provider struct { NPM string `json:"npm"` API string `json:"api"` Methods []Method `json:"methods"` + // Icon is the picture the plugin gives the provider, as it said it: + // an https URL or a data:image URI (internal/provider keeps it) + Icon string `json:"icon,omitempty"` // Usage says the plugin tells each account's allowance (auth.usage) Usage bool `json:"usage"` SignedIn bool `json:"signedIn"` diff --git a/internal/plugin/testdata/ownicon/index.js b/internal/plugin/testdata/ownicon/index.js new file mode 100644 index 00000000..e2138410 --- /dev/null +++ b/internal/plugin/testdata/ownicon/index.js @@ -0,0 +1,23 @@ +// A plugin giving its providers their own icons and its key a title and a +// hint: the auth hook's icon (magpie's own field), else package.json's +// magpie.icon; an "api" method's label and placeholder. +const svg = "data:image/svg+xml;base64," + btoa('') + +export const Lemon = async () => ({ + auth: { + provider: "lemon", + icon: svg, + methods: [ + { type: "api", label: "Lemon API key (from lemon.example/keys)", placeholder: "sk-lemon-…" }, + { type: "oauth", label: "Browser", placeholder: "not a key's", authorize: async () => ({ url: "https://lemon.invalid", instructions: "", method: "auto", callback: async () => ({ type: "failed" }) }) }, + ], + }, +}) + +export const Lime = async () => ({ + auth: { + provider: "lime", + icon: "javascript:alert(1)", // refused: package.json's instead + methods: [{ type: "api", label: "API key", placeholder: 42 }], + }, +}) diff --git a/internal/plugin/testdata/ownicon/package.json b/internal/plugin/testdata/ownicon/package.json new file mode 100644 index 00000000..5c6468ad --- /dev/null +++ b/internal/plugin/testdata/ownicon/package.json @@ -0,0 +1,7 @@ +{ + "name": "opencode-ownicon-auth", + "version": "1.0.0", + "type": "module", + "main": "index.js", + "magpie": { "icon": "https://ownicon.example/icon.png" } +} diff --git a/internal/provider/icon.go b/internal/provider/icon.go index 97957523..e77f2b63 100644 --- a/internal/provider/icon.go +++ b/internal/provider/icon.go @@ -309,8 +309,9 @@ func iconExt(b []byte) string { return "" } -// pruneIcons removes pictures no provider points at any more. One picked in -// the editor is stored before its provider is saved, so a fresh one stays. +// pruneIcons removes pictures no provider points at any more, nor a plugin +// gave its provider (PluginIcon). One picked in the editor is stored +// before its provider is saved, so a fresh one stays. func pruneIcons(f file) { used := map[string]bool{} for _, p := range f.Providers { @@ -318,6 +319,9 @@ func pruneIcons(f file) { used[name] = true } } + for _, name := range keptPluginIcons() { + used[name] = true + } ents, _ := os.ReadDir(IconDir()) for _, e := range ents { if !iconName.MatchString(e.Name()) || used[e.Name()] { diff --git a/internal/provider/plugin_icon.go b/internal/provider/plugin_icon.go new file mode 100644 index 00000000..4357819d --- /dev/null +++ b/internal/provider/plugin_icon.go @@ -0,0 +1,128 @@ +package provider + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/yetone/magpie/internal/plugin" +) + +// PluginIcon is the icon a plugin's provider shows: the picture its plugin +// gives it (the auth hook's icon, or package.json's magpie.icon), kept as +// a picture given by hand is, else the one the plugin market gives it. +func PluginIcon(pp plugin.Provider) string { + if ic := pluginOwnIcon(pp.Icon); ic != "" { + return ic + } + return plugin.Icon(pp.Spec, pp.ID) +} + +// plugIcons are the pictures plugins gave, by their hash: the Icon value +// each is kept as, and when one was last tried (a URL being fetched or +// that failed, a data URI that isn't a picture). +var plugIcons struct { + sync.Mutex + from string // the file they were read from + kept map[string]string + tried map[string]time.Time +} + +// fetchPluginIcon fetches an https picture as an import link's is: a +// public host only, at most MaxIcon. Tests point it elsewhere. +var fetchPluginIcon = FetchIcon + +func plugIconsPath() string { return filepath.Join(filepath.Dir(Path()), "plugin-icons.json") } + +func loadPlugIcons() { + if plugIcons.from == plugIconsPath() { + return + } + plugIcons.from = plugIconsPath() + plugIcons.kept, plugIcons.tried = map[string]string{}, map[string]time.Time{} + if b, err := os.ReadFile(plugIcons.from); err == nil { + _ = json.Unmarshal(b, &plugIcons.kept) + } +} + +func keepPlugIcon(k, icon string) { + plugIcons.kept[k] = icon + delete(plugIcons.tried, k) + if b, err := json.Marshal(plugIcons.kept); err == nil { + _ = os.MkdirAll(filepath.Dir(plugIcons.from), 0o755) + _ = os.WriteFile(plugIcons.from, b, 0o644) + } +} + +// pluginOwnIcon is the Icon value of the picture a plugin gave, "" while +// there is none to show. A data:image URI is kept at once; an https URL +// is fetched in the background, through FetchIcon's checks, and shows +// once it is in (a failure is tried again in an hour). Anything else is +// refused. +func pluginOwnIcon(said string) string { + if said == "" { + return "" + } + sum := sha256.Sum256([]byte(said)) + k := hex.EncodeToString(sum[:16]) + plugIcons.Lock() + defer plugIcons.Unlock() + loadPlugIcons() + if ic := plugIcons.kept[k]; ic != "" { + if f := IconFile(strings.TrimPrefix(ic, iconPrefix)); f != "" { + if _, err := os.Stat(f); err == nil { + return ic + } + } + } + if t, ok := plugIcons.tried[k]; ok && time.Since(t) < time.Hour { + return "" + } + plugIcons.tried[k] = time.Now() + if len(said) > 11 && strings.EqualFold(said[:11], "data:image/") { + if b := dataURI(said[len("data:"):]); b != nil { + if ic, err := StoreIcon(b); err == nil { + keepPlugIcon(k, ic) + return ic + } + } + return "" + } + u, err := iconURL(said) + if err != nil { + return "" + } + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + ic, err := fetchPluginIcon(ctx, u) + if err != nil { + return + } + plugIcons.Lock() + keepPlugIcon(k, ic) + plugIcons.Unlock() + }() + return "" +} + +// keptPluginIcons are the names of the stored pictures plugins gave, which +// pruneIcons leaves. +func keptPluginIcons() []string { + plugIcons.Lock() + defer plugIcons.Unlock() + loadPlugIcons() + var out []string + for _, ic := range plugIcons.kept { + if name, ok := strings.CutPrefix(ic, iconPrefix); ok { + out = append(out, name) + } + } + return out +} diff --git a/internal/provider/plugin_icon_test.go b/internal/provider/plugin_icon_test.go new file mode 100644 index 00000000..327623ae --- /dev/null +++ b/internal/provider/plugin_icon_test.go @@ -0,0 +1,95 @@ +package provider + +import ( + "context" + "encoding/base64" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/yetone/magpie/internal/plugin" +) + +// Lemon on Discord: can a plugin give its provider its own icon? The auth +// hook's icon (or package.json's magpie.icon) is a data:image URI, kept +// at once as a picture given by hand is, or an https URL, fetched in the +// background through FetchIcon's checks; it comes before the market's, +// and anything else is refused. +func TestPluginOwnIcon(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) + t.Setenv("MAGPIE_PLUGIN_MARKET", "off") + + png := []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR") + var fetched atomic.Int32 + was := fetchPluginIcon + t.Cleanup(func() { fetchPluginIcon = was }) + fetchPluginIcon = func(_ context.Context, u string) (string, error) { + fetched.Add(1) + if u != "https://lemon.example/icon.png" { + t.Errorf("fetched %q", u) + } + return StoreIcon(png) + } + + // a data URI: kept at once, over the market's icon for its provider + data := "data:image/png;base64," + base64.StdEncoding.EncodeToString(png) + ic := PluginIcon(plugin.Provider{ID: "google", Spec: "lemon-plugin", Icon: data}) + if !strings.HasPrefix(ic, "file:") { + t.Fatalf("data URI icon = %q", ic) + } + if f := IconFile(strings.TrimPrefix(ic, "file:")); f == "" { + t.Fatalf("%q isn't a stored picture", ic) + } + // none given: the market's, as before + if ic := PluginIcon(plugin.Provider{ID: "google", Spec: "lemon-plugin"}); ic != "gemini-color" { + t.Errorf("no icon given = %q", ic) + } + + // an https URL: the market's (none here) until it is fetched, then its + pp := plugin.Provider{ID: "lemon", Spec: "lemon-plugin", Icon: "https://lemon.example/icon.png"} + if ic := PluginIcon(pp); ic != "" { + t.Errorf("before the fetch = %q", ic) + } + var got string + for i := 0; i < 100 && !strings.HasPrefix(got, "file:"); i++ { + time.Sleep(20 * time.Millisecond) + got = PluginIcon(pp) + } + if !strings.HasPrefix(got, "file:") || fetched.Load() != 1 { + t.Fatalf("after the fetch = %q, fetched %d times", got, fetched.Load()) + } + + // refused: not https, not a picture, a private host, a picture over 1 MB + big := "data:image/png;base64," + base64.StdEncoding.EncodeToString(append(png, make([]byte, MaxIcon)...)) + for _, bad := range []string{"http://lemon.example/icon.png", "javascript:alert(1)", "data:text/html,x", "https://127.0.0.1/icon.png", "https://localhost/icon.png", "file:///etc/passwd", big} { + if ic := PluginIcon(plugin.Provider{ID: "lemon", Spec: "lemon-plugin", Icon: bad}); ic != "" { + t.Errorf("%.40q taken as %q", bad, ic) + } + } + time.Sleep(50 * time.Millisecond) + if fetched.Load() != 1 { + t.Errorf("fetched %d times", fetched.Load()) + } + + // kept across a restart, and not pruned as a picture no provider has + plugIcons.Lock() + plugIcons.from = "" + plugIcons.Unlock() + old := time.Now().Add(-2 * time.Hour) + for _, name := range keptPluginIcons() { + os.Chtimes(IconFile(name), old, old) + } + pruneIcons(file{}) + if ic := PluginIcon(pp); ic != got { + t.Errorf("after a restart and a prune = %q, want %q", ic, got) + } + if fetched.Load() != 1 { + t.Errorf("fetched again: %d", fetched.Load()) + } +} diff --git a/internal/provider/plugin_usage.go b/internal/provider/plugin_usage.go index dddf0960..8230ad50 100644 --- a/internal/provider/plugin_usage.go +++ b/internal/provider/plugin_usage.go @@ -65,7 +65,7 @@ func pluginCard(pp plugin.Provider) (string, string) { if name == "" { name = pp.ID } - return name, plugin.Icon(pp.Spec, pp.ID) + return name, PluginIcon(pp) } // pluginUsageLogins are the accounts of pp whose allowance can be asked. diff --git a/internal/provider/plugins.go b/internal/provider/plugins.go index 8538879d..95111d63 100644 --- a/internal/provider/plugins.go +++ b/internal/provider/plugins.go @@ -212,7 +212,7 @@ func pluginProvider(pp plugin.Provider, l pluginLogin) Provider { } a.sign = func(ctx context.Context, req *http.Request, body []byte) error { return nil } a.transport = func(req *http.Request) (*http.Response, error) { return pluginFetch(pp, acct.Key, req) } - p := Provider{ID: id, Name: name, Icon: plugin.Icon(pp.Spec, pp.ID), Account: a} + p := Provider{ID: id, Name: name, Icon: PluginIcon(pp), Account: a} if c, ok := movedCards[pp.ID]; ok && Moved(pp.ID) { p.Name, p.Icon, p.Website = c.name, c.icon, c.site // as the built-in was m, _ := MigrationOf(pp.ID) diff --git a/plugin_key_cli_test.go b/plugin_key_cli_test.go new file mode 100644 index 00000000..ebd579bb --- /dev/null +++ b/plugin_key_cli_test.go @@ -0,0 +1,20 @@ +package main + +import ( + "strings" + "testing" + + "github.com/yetone/magpie/internal/plugin" +) + +// magpie provider login asks a plugin's key by its method's label, with +// the placeholder the plugin gives as its hint (Lemon on Discord). +func TestPluginKeyPrompt(t *testing.T) { + p := keyPrompt("Lemon", plugin.Method{Type: "api", Label: "Lemon API key (from lemon.example/keys)", Placeholder: "sk-lemon-…"}) + if !strings.HasPrefix(p, "Lemon API key (from lemon.example/keys) ") || !strings.Contains(p, "(sk-lemon-…)") || !strings.HasSuffix(p, ": ") { + t.Errorf("prompt = %q", p) + } + if p := keyPrompt("Lime", plugin.Method{Type: "api", Label: "API key"}); p != "Lime API key: " { + t.Errorf("generic label's prompt = %q", p) + } +} diff --git a/plugins_cli.go b/plugins_cli.go index 83eb0623..3633e135 100644 --- a/plugins_cli.go +++ b/plugins_cli.go @@ -285,7 +285,7 @@ func pluginLogin(ctx context.Context, name, method string) error { } var saved plugin.Saved if pp.Methods[m].Type == "api" { - key, err := secret("key", pp.Name+" API key: ", false) + key, err := secret("key", keyPrompt(pp.Name, pp.Methods[m]), false) if err != nil { return err } @@ -324,6 +324,16 @@ func pluginLogin(ctx context.Context, name, method string) error { return nil } +// keyPrompt asks an "api" method's key: its title, then the hint the +// plugin gives (its placeholder), as a question's is. +func keyPrompt(name string, m plugin.Method) string { + p := m.KeyTitle(name) + if m.Placeholder != "" { + p += " " + muted.Render("("+m.Placeholder+")") + } + return p + ": " +} + func askNumber(n int) (int, error) { for { fmt.Print("> ")