providers: a built-in subscription moves onto its community plugin and back, its accounts with it (the user: 慢慢下掉内置的那些 Subscription Provider…不要让用户一升级完之后,突然发现他的所有服务都不可用了) — eleven built-ins (Zed, Qoder, Factory, MiMo, Devin, Grok, Command Code Plan, Cursor, Kiro, WorkBuddy, ZCode) each get a mover to their @magpie-community package. A move installs the plugin (updated when older than the mover needs), hands it every account in the plugin's shape, checks the plugin lists models for them, and only then lets the plugin serve the same provider id, so agents' model ids don't change; any failure rolls back and the built-in keeps serving. The built-in's accounts are kept as a backup, and "Run built-in again" (magpie plugin move-back <id>) takes the plugin's current sign-ins back, never copying a rotating token: they are taken out of the plugin in one step, and an account the built-in renewed meanwhile keeps its newer token. An account only the plugin can hold (Cursor's browser sign-in) stays with it. The provider editor shows "Runs on" with the button, a failed move's reason, and the CLI has magpie plugin move / move-back. Automatic moves (Retiring) are wired in but the list stays empty until the packages are on npm. Checked with real accounts in a sandbox HOME: Command Code Plan and Devin moved, answered a chat through the gateway on the plugin, moved back and answered again on the built-in. TestMove*, TestPutBackKeepsNewer, the movers' round-trip tests and plugin-move.test.cjs (Chromium, WebKit, en/zh) are new. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; click-scroll, old-webkit pass.
This commit is contained in:
26 files changed
+3016
-5
No files matched your search
@@ -275,6 +275,8 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
go provider.KeepClaudeWindowsWarm(ctx, warmClaude)
|
||||
// and checks the WorkBuddy accounts in for the day's credits
|
||||
go provider.KeepWorkBuddyCheckedIn(ctx)
|
||||
// and moves the built-in subscriptions being retired onto their plugins
|
||||
go provider.KeepRetiringMoved(ctx)
|
||||
for _, f := range WhileServing {
|
||||
go f(ctx)
|
||||
}
|
||||
|
||||
@@ -1442,6 +1442,9 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; }
|
||||
.editor .side .text { padding: 3px 6px; }
|
||||
.editor .headers .pair .side .text { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; padding: 0; border-radius: 6px; }
|
||||
.editor .stack { display: flex; flex-direction: column; gap: 6px; }
|
||||
.editor .move { align-items: flex-start; font-size: 12px; line-height: 1.5; padding-top: 4px; }
|
||||
.editor .move .hint { margin-top: 0; }
|
||||
.editor .move > button { padding-left: 0; padding-right: 0; }
|
||||
.editor .headers { display: flex; flex-direction: column; gap: 6px; align-items: flex-start; }
|
||||
.editor .headers .pair { width: 100%; }
|
||||
.editor .headers .hadds { display: flex; flex-wrap: wrap; gap: 2px 12px; }
|
||||
|
||||
@@ -2843,7 +2843,7 @@ function renderAdd() {
|
||||
return grid;
|
||||
};
|
||||
let any = false;
|
||||
const subs = SUBS.filter((x) => !f || x.name.toLowerCase().includes(f) || x.agent.includes(f) || "subscription".includes(f));
|
||||
const subs = SUBS.filter((x) => !(providers.onPlugins || []).includes(x.agent)).filter((x) => !f || x.name.toLowerCase().includes(f) || x.agent.includes(f) || "subscription".includes(f));
|
||||
if (subs.length) {
|
||||
any = true;
|
||||
const grid = section("Subscriptions", "sign in, no key");
|
||||
@@ -3656,6 +3656,7 @@ function drawEditor(p, presetID) {
|
||||
if (subOf(a.agent)) {
|
||||
ed.append(...field(t("Accounts"), renderAccounts(a), p.routing ? t("Tick every account to use; Routing says how requests spread over them.") : subOf(a.agent).single ? t("{agent} keeps one account; the gateway runs it for every request. Signing in to another replaces it.", { agent: a.agentName }) : subOf(a.agent).plugin ? t("The gateway uses the first. Tick more and it moves on to the next when the one before it is out of quota.") : subOf(a.agent).own ? t("The gateway uses the first. Tick more and it moves on to the next when the one before it is out of quota. {agent} itself stays signed in as it is.", { agent: a.agentName }) : t("{agent} signs in to the first. Tick more and the gateway moves on to the next when the one before it is out of quota. Sessions already running keep theirs until restarted.", { agent: a.agentName })));
|
||||
if ((a.logins || []).filter((l) => l.active || l.on).length > 1) ed.append(...renderRouting(p));
|
||||
if (p.move) ed.append(...renderMove(p));
|
||||
} else {
|
||||
const acct = el("div", "acct");
|
||||
acct.append(icon(a.agentIcon), el("span", "n", a.user), el("span", "plan", accountPlan(a)));
|
||||
@@ -5882,6 +5883,27 @@ function respellURL(u, api) {
|
||||
return /\/v\d+[a-z]*$/.test(u) || !/^https?:\/\/[^/]+$/.test(u) ? u : u + "/v1";
|
||||
}
|
||||
|
||||
// renderMove: a built-in subscription a community plugin can run — which
|
||||
// of the two runs it, and the way to the other. Accounts, models and the
|
||||
// agents on them stay as they are either way.
|
||||
function renderMove(p) {
|
||||
const m = p.move;
|
||||
const box = el("div", "stack move");
|
||||
const onPlugin = m.state === "plugin";
|
||||
box.append(el("div", "", onPlugin
|
||||
? t("The community plugin {pkg}, with the accounts you had here.", { pkg: m.package })
|
||||
: t("magpie's built-in sign-in. The community plugin {pkg} can run it instead, with the same accounts and models.", { pkg: m.package })));
|
||||
if (m.state === "failed" && m.error) box.append(el("div", "hint", t("The last move didn't go through, so it stays built-in: {error}", { error: m.error })));
|
||||
const b = el("button", "text", onPlugin ? t("Use the built-in again") : t("Move to the plugin"));
|
||||
b.onclick = () => {
|
||||
b.classList.add("busy");
|
||||
b.disabled = true;
|
||||
providerAction(onPlugin ? "moveback" : "move", { id: p.id }, onPlugin ? t("{name} is built-in again", { name: p.name }) : t("{name} runs on its plugin now", { name: p.name }));
|
||||
};
|
||||
box.append(b);
|
||||
return field(t("Runs on"), box, onPlugin ? t("Going back puts every account, with the plugin's newer sign-ins, back into the built-in.") : t("If an account doesn't work through the plugin, nothing changes."));
|
||||
}
|
||||
|
||||
async function providerAction(action, body, okMsg, base = "provider/") {
|
||||
try {
|
||||
providers = await api(base + action, body);
|
||||
|
||||
@@ -341,6 +341,16 @@ const I18N = {
|
||||
"{agent} and the gateway use the one in use. Sessions already running keep theirs until restarted.": "{agent} 和网关使用标记为「使用中」的账号。已在运行的会话保持原账号,重启后切换。",
|
||||
"The gateway uses the first. Tick more and it moves on to the next when the one before it is out of quota. {agent} itself stays signed in as it is.": "网关优先用第一个账号。多勾选几个,前一个额度用完时会自动换到下一个。{agent} 本身的登录保持不变。",
|
||||
"The gateway uses the first. Tick more and it moves on to the next when the one before it is out of quota.": "网关优先用第一个账号。多勾选几个,前一个额度用完时会自动换到下一个。",
|
||||
"Runs on": "运行方式",
|
||||
"The community plugin {pkg}, with the accounts you had here.": "社区插件 {pkg},沿用你在这里的账号。",
|
||||
"magpie's built-in sign-in. The community plugin {pkg} can run it instead, with the same accounts and models.": "magpie 内置的登录。也可以改由社区插件 {pkg} 运行,账号和模型保持不变。",
|
||||
"The last move didn't go through, so it stays built-in: {error}": "上次迁移没有成功,仍使用内置:{error}",
|
||||
"Use the built-in again": "改回内置",
|
||||
"Move to the plugin": "迁移到插件",
|
||||
"{name} is built-in again": "{name} 已改回内置",
|
||||
"{name} runs on its plugin now": "{name} 现在由插件运行",
|
||||
"Going back puts every account, with the plugin's newer sign-ins, back into the built-in.": "改回时,所有账号(连同插件里更新过的登录)都会回到内置。",
|
||||
"If an account doesn't work through the plugin, nothing changes.": "只要有账号通过插件用不了,就什么都不会改动。",
|
||||
"The gateway uses this account first": "网关优先使用这个账号",
|
||||
"Routing": "路由",
|
||||
"Tick every key to use; Routing says how requests spread over them.": "勾选的密钥会同时启用;请求怎么分给它们,由下面的「路由」决定。",
|
||||
|
||||
@@ -95,6 +95,16 @@ type providerJSON struct {
|
||||
Sponsored bool `json:"sponsored"`
|
||||
KeyList []provider.KeyInfo `json:"keyList"` // its keys, in the order requests try them
|
||||
Account *accountJSON `json:"account,omitempty"` // a signed-in agent, see provider.Account
|
||||
// Move is where a built-in subscription stands with the community
|
||||
// plugin that can run it (provider.Move): set for those that have one
|
||||
Move *moveJSON `json:"move,omitempty"`
|
||||
}
|
||||
|
||||
type moveJSON struct {
|
||||
Package string `json:"package"`
|
||||
// State is "" (built-in, never moved), "plugin", "back" or "failed"
|
||||
State string `json:"state"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// stepPlanJSON: whether a StepFun provider's platform sign-in is kept, and
|
||||
@@ -167,6 +177,9 @@ type providersJSON struct {
|
||||
CodexDaemon string `json:"codexDaemon,omitempty"`
|
||||
// Plugins are the providers the plugins sign in to, for the add sheet
|
||||
Plugins []pluginSubJSON `json:"plugins"`
|
||||
// OnPlugins are the built-in subscriptions moved onto their plugins,
|
||||
// which the add sheet offers as the plugin's alone
|
||||
OnPlugins []string `json:"onPlugins,omitempty"`
|
||||
// Moved is the agents the change moved off models it stopped serving
|
||||
// (agent.Reseat), for the page to say so.
|
||||
Moved []agent.Move `json:"moved,omitempty"`
|
||||
@@ -238,6 +251,13 @@ func providerInfo(p provider.Provider, agents []agentUse) providerJSON {
|
||||
}
|
||||
out.Key.Set = p.Key != ""
|
||||
out.Key.Masked = provider.Mask(p.Key)
|
||||
if provider.Movable(p.ID) {
|
||||
m, _ := provider.MigrationOf(p.ID)
|
||||
out.Move = &moveJSON{Package: provider.MovePackage(p.ID), State: m.State, Error: m.Err}
|
||||
if m.State == provider.MoveMoving {
|
||||
out.Move.State = ""
|
||||
}
|
||||
}
|
||||
out.KeyList = p.KeyList()
|
||||
if out.KeyList == nil {
|
||||
out.KeyList = []provider.KeyInfo{}
|
||||
@@ -357,6 +377,7 @@ func providersState() providersJSON {
|
||||
provider.FetchNew(8 * time.Second)
|
||||
agents := agent.Detected()
|
||||
s := providersJSON{Providers: []providerJSON{}, Presets: []presetJSON{}, Excluded: []excludedJSON{}}
|
||||
s.OnPlugins = provider.OnPlugins()
|
||||
for _, x := range provider.Excluded() {
|
||||
e := excludedJSON{Exclusion: x, Name: x.Agent, Icon: "generic"}
|
||||
if a, err := agent.Find(x.Agent); err == nil {
|
||||
@@ -524,6 +545,17 @@ func providerRoutes(mux *http.ServeMux, w Windows) {
|
||||
fail(rw, err)
|
||||
return
|
||||
}
|
||||
case "move":
|
||||
// a built-in subscription's accounts onto its community plugin
|
||||
if err := provider.Move(r.Context(), in.ID); err != nil {
|
||||
fail(rw, err)
|
||||
return
|
||||
}
|
||||
case "moveback":
|
||||
if err := provider.MoveBack(r.Context(), in.ID); err != nil {
|
||||
fail(rw, err)
|
||||
return
|
||||
}
|
||||
case "quiet":
|
||||
// a removed account's "Add it back" line, dismissed (#116)
|
||||
if err := provider.QuietAccount(in.ID); err != nil {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// Run with Node's test runner and Playwright on the module path; see README.md.
|
||||
// A built-in subscription a community plugin can run (Zed here): its
|
||||
// editor says which runs it, "Move to the plugin" posts provider/move and
|
||||
// then says the plugin runs it, with "Use the built-in again" posting
|
||||
// provider/moveback; a failed move says why it stays built-in. The add
|
||||
// sheet no longer offers the built-in Zed once it runs on its plugin. In
|
||||
// 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 pkg = "@magpie-community/opencode-zed-auth";
|
||||
|
||||
function serve(lang, posts) {
|
||||
let move = { package: pkg, state: "failed", error: "the plugin doesn't serve claude-sonnet-5" };
|
||||
const payload = () => {
|
||||
const onPlugin = move.state === "plugin";
|
||||
const zed = {
|
||||
id: "zed", name: "Zed", icon: "zed", chat: "", responses: "", anthropic: "", catalog: "",
|
||||
models: [{ id: "claude-sonnet-5", name: "Claude Sonnet 5", on: true }], agents: [], fallback: [], headers: {}, keyList: [], key: {},
|
||||
account: { agent: onPlugin ? "zed" : "zed", agentName: "Zed", user: "ada", logins: [{ user: "ada", active: true, on: true }, { user: "bob", on: true }] },
|
||||
move,
|
||||
};
|
||||
return {
|
||||
providers: [zed], presets: [], excluded: [], gateway: { running: true, window: true },
|
||||
plugins: onPlugin ? [{ id: "zed", pid: "zed", name: "Zed", icon: "zed", spec: pkg, signedIn: true, models: 1, methods: [] }] : [],
|
||||
onPlugins: onPlugin ? ["zed"] : [],
|
||||
};
|
||||
};
|
||||
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:true};` });
|
||||
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(payload());
|
||||
if (url.pathname === "/api/groups") return json({ groups: [] });
|
||||
if (url.pathname === "/api/provider/move" || url.pathname === "/api/provider/moveback") {
|
||||
posts.push({ path: url.pathname, body: route.request().postDataJSON() });
|
||||
move = { package: pkg, state: url.pathname.endsWith("moveback") ? "back" : "plugin" };
|
||||
return json(payload());
|
||||
}
|
||||
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: { runs: "Runs on", move: "Move to the plugin", back: "Use the built-in again", failed: "The last move didn't go through, so it stays built-in: the plugin doesn't serve claude-sonnet-5", onPlugin: "The community plugin " + pkg, builtin: "magpie's built-in sign-in", subs: "Subscriptions" },
|
||||
zh: { runs: "运行方式", move: "迁移到插件", back: "改回内置", failed: "上次迁移没有成功,仍使用内置:the plugin doesn't serve claude-sonnet-5", onPlugin: "社区插件 " + pkg, builtin: "magpie 内置的登录", subs: "订阅" },
|
||||
};
|
||||
|
||||
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
|
||||
for (const lang of ["en", "zh"]) {
|
||||
test(`${engine} ${lang}: a built-in subscription moves to its plugin and back`, async (t) => {
|
||||
const w = L[lang];
|
||||
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
|
||||
t.after(() => browser.close());
|
||||
const page = await (await browser.newContext({ viewport: { width: 900, height: 800 }, reducedMotion: "reduce" })).newPage();
|
||||
page.setDefaultTimeout(5000);
|
||||
const errors = [], posts = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
await page.route("**/*", serve(lang, posts));
|
||||
await page.goto("http://magpie.test/?view=providers");
|
||||
await page.locator(".row.provider", { hasText: "Zed" }).click();
|
||||
const ed = page.locator("#modal .editor");
|
||||
const btn = ed.locator("button", { hasText: w.move });
|
||||
await btn.waitFor();
|
||||
const text = await ed.innerText();
|
||||
assert.ok(text.includes(w.runs), "no Runs on field");
|
||||
assert.ok(text.includes(w.builtin), "doesn't say it's built-in");
|
||||
assert.ok(text.includes(w.failed), "doesn't say why the last move failed");
|
||||
if (process.env.ARTIFACT_DIR) await ed.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `plugin-move-${engine}-${lang}.png`) });
|
||||
|
||||
await btn.click();
|
||||
for (let i = 0; i < 50 && !posts.length; i++) await page.waitForTimeout(50);
|
||||
assert.deepEqual(posts.map((p) => [p.path, p.body.id]), [["/api/provider/move", "zed"]]);
|
||||
|
||||
// the editor again: the plugin runs it now, and the way back
|
||||
await page.locator(".row.provider", { hasText: "Zed" }).click();
|
||||
const back = ed.locator("button", { hasText: w.back });
|
||||
await back.waitFor();
|
||||
assert.ok((await ed.innerText()).includes(w.onPlugin), "doesn't name the plugin");
|
||||
if (process.env.ARTIFACT_DIR) await ed.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `plugin-moved-${engine}-${lang}.png`) });
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// the add sheet offers Zed once, as the plugin's
|
||||
await page.locator("#addProvider").click();
|
||||
const sheet = page.locator("#addSheet");
|
||||
await sheet.locator(".kind b", { hasText: w.subs }).waitFor();
|
||||
assert.equal(await sheet.locator('.tile[data-pick="Zed"]').count(), 1);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.locator(".row.provider", { hasText: "Zed" }).click();
|
||||
await back.click();
|
||||
for (let i = 0; i < 50 && posts.length < 2; i++) await page.waitForTimeout(50);
|
||||
assert.deepEqual(posts.map((p) => p.path), ["/api/provider/move", "/api/provider/moveback"]);
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
}
|
||||
}
|
||||
+40
-2
@@ -391,7 +391,10 @@ function fromModelsDev(p, m) {
|
||||
// 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, key) {
|
||||
// info is what OpenCode knows of provider id, its models as the plugin
|
||||
// lists them for account key (the first when none); strict fails where the
|
||||
// plugin's list does, rather than keeping the list it was given.
|
||||
async function info(id, key, strict) {
|
||||
const md = mdev()[id]
|
||||
const cfg = config.provider?.[id]
|
||||
const out = {
|
||||
@@ -433,9 +436,11 @@ async function info(id, key) {
|
||||
if (ph?.id !== id || typeof ph.models !== "function") continue
|
||||
try {
|
||||
const all = readAuth()
|
||||
const next = await ph.models(JSON.parse(JSON.stringify(out)), { auth: all[key ?? accountsOf(all, id)[0] ?? id] })
|
||||
const k = key ?? accountsOf(all, id)[0] ?? id
|
||||
const next = await inScope(id, k, () => ph.models(JSON.parse(JSON.stringify(out)), { auth: all[k] }))
|
||||
out.models = Object.fromEntries(Object.entries(next ?? {}).map(([k, m]) => [k, { ...m, id: k, providerID: id }]))
|
||||
} catch (e) {
|
||||
if (strict) throw e
|
||||
send({ event: "log", level: "error", message: `${h.spec}: provider.models: ${e?.message ?? e}` })
|
||||
}
|
||||
}
|
||||
@@ -703,6 +708,39 @@ const handlers = {
|
||||
callback,
|
||||
apiKey,
|
||||
load,
|
||||
// check tries one account as a request would: its loader, then its
|
||||
// models as the plugin lists them for it
|
||||
async check(p) {
|
||||
const key = accountKey(p.provider, p.account)
|
||||
await load({ provider: p.provider, account: key })
|
||||
const pi = await info(p.provider, key, true)
|
||||
return { models: Object.keys(pi.models) }
|
||||
},
|
||||
// import keeps a sign-in made elsewhere (a built-in subscription's, moved
|
||||
// onto its plugin) as one more account, or as the account it already is
|
||||
// import keeps a sign-in made elsewhere as one of provider's accounts;
|
||||
// with a key, as that account again (one taken back)
|
||||
import(p) {
|
||||
if (p.key && providerOf(p.key) === p.provider) {
|
||||
setAuth(p.key, p.auth)
|
||||
return { account: p.key }
|
||||
}
|
||||
const key = freshKey(p.provider)
|
||||
setAuth(key, p.auth)
|
||||
return { account: settle(p.provider, key) }
|
||||
},
|
||||
// take gives the accounts named (else every account of the provider) and
|
||||
// forgets them in one step: nothing renews a token between the two
|
||||
take(p) {
|
||||
const all = readAuth()
|
||||
const out = {}
|
||||
for (const k of p.accounts?.length ? p.accounts : accountsOf(all, p.provider)) {
|
||||
if (!(k in all)) continue
|
||||
out[k] = all[k]
|
||||
removeAuth(k)
|
||||
}
|
||||
return { auths: out }
|
||||
},
|
||||
// signOut forgets the account named, else every account of the provider
|
||||
signOut(p) {
|
||||
const keys = p.account ? [p.account] : accountsOf(readAuth(), p.provider)
|
||||
|
||||
@@ -401,3 +401,63 @@ func init() {
|
||||
optMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
// Import keeps auth (a plugin-auth.json entry) as one more of provider's
|
||||
// accounts — or, the same account as one kept already, in its place — and
|
||||
// gives the account's key. The plugin's host is started for it: it alone
|
||||
// writes the file while it runs.
|
||||
func Import(ctx context.Context, provider string, auth map[string]any) (string, error) {
|
||||
var r struct {
|
||||
Account string `json:"account"`
|
||||
}
|
||||
if err := Call(ctx, "import", map[string]any{"provider": provider, "auth": auth}, &r); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return r.Account, nil
|
||||
}
|
||||
|
||||
// Check tries one of provider's accounts as a request would — its auth
|
||||
// loader, then its models as the plugin lists them for it — and gives the
|
||||
// model ids.
|
||||
func Check(ctx context.Context, provider, account string) ([]string, error) {
|
||||
var r struct {
|
||||
Models []string `json:"models"`
|
||||
}
|
||||
if err := Call(ctx, "check", map[string]any{"provider": provider, "account": account}, &r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.Models, nil
|
||||
}
|
||||
|
||||
// Auths are provider's sign-ins as plugin-auth.json keeps them, by key.
|
||||
func Auths(provider string) map[string]map[string]any {
|
||||
var m map[string]map[string]any
|
||||
if b, err := os.ReadFile(AuthPath()); err == nil {
|
||||
_ = json.Unmarshal(b, &m)
|
||||
}
|
||||
out := map[string]map[string]any{}
|
||||
for k, v := range m {
|
||||
if ProviderOf(k) == provider {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Take gives provider's accounts named by keys (every one when there are
|
||||
// none) and signs them out in one step, so the plugin renews none of their
|
||||
// tokens after it gave them.
|
||||
func Take(ctx context.Context, provider string, keys []string) (map[string]map[string]any, error) {
|
||||
var r struct {
|
||||
Auths map[string]map[string]any `json:"auths"`
|
||||
}
|
||||
if err := Call(ctx, "take", map[string]any{"provider": provider, "accounts": keys}, &r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.Auths, nil
|
||||
}
|
||||
|
||||
// Restore puts auth back as provider's account key.
|
||||
func Restore(ctx context.Context, provider, key string, auth map[string]any) error {
|
||||
return Call(ctx, "import", map[string]any{"provider": provider, "key": key, "auth": auth}, nil)
|
||||
}
|
||||
@@ -268,3 +268,38 @@ func command(ctx context.Context, name string, args ...string) *exec.Cmd {
|
||||
|
||||
// env is the plugins' environment: magpie's, with its proxy.
|
||||
func env() []string { return netproxy.Env(os.Environ()) }
|
||||
|
||||
// Version is the version of the package spec installed (or checked out
|
||||
// at its path), "" when there is none.
|
||||
func Version(spec string) string {
|
||||
dir := Target(spec)
|
||||
if fi, err := os.Stat(dir); err == nil && !fi.IsDir() {
|
||||
dir = filepath.Dir(dir)
|
||||
}
|
||||
var pj struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if b, err := os.ReadFile(filepath.Join(dir, "package.json")); err == nil && json.Unmarshal(b, &pj) == nil {
|
||||
return pj.Version
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// PackageName is the npm package spec is: its name, or, for a plugin added
|
||||
// from a folder or file (a checkout of it), the name its package.json gives.
|
||||
func PackageName(spec string) string {
|
||||
if !IsPath(spec) {
|
||||
return Name(spec)
|
||||
}
|
||||
dir := Target(spec)
|
||||
if fi, err := os.Stat(dir); err == nil && !fi.IsDir() {
|
||||
dir = filepath.Dir(dir)
|
||||
}
|
||||
var pj struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if b, err := os.ReadFile(filepath.Join(dir, "package.json")); err == nil && json.Unmarshal(b, &pj) == nil && pj.Name != "" {
|
||||
return pj.Name
|
||||
}
|
||||
return spec
|
||||
}
|
||||
+13
@@ -52,6 +52,19 @@ export const FakePlugin = async ({ client }) => ({
|
||||
},
|
||||
}),
|
||||
},
|
||||
// the models an account has: refused for a dead one; a "rot-" sign-in
|
||||
// spends its refresh token asking, as a rotating one does
|
||||
provider: {
|
||||
id: "fakeco",
|
||||
models: async (p, { auth }) => {
|
||||
if (auth?.refresh === "r-dead" || auth?.key === "dead") throw new Error("the vendor refused the sign-in")
|
||||
if (auth?.type === "oauth" && auth.refresh?.startsWith("rot-")) {
|
||||
await client.auth.set({ path: { id: "fakeco" }, body: { ...auth, refresh: auth.refresh + "+" } })
|
||||
}
|
||||
if (auth?.key === "few") return { "fake-1": p.models["fake-1"] }
|
||||
return p.models
|
||||
},
|
||||
},
|
||||
"chat.headers": async (input, output) => {
|
||||
if (input.model.providerID === "fakeco") output.headers["x-plugin-model"] = input.model.id
|
||||
},
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -842,6 +843,8 @@ func Accounts() []Provider {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
// a built-in moved onto its plugin is the plugin's now (migrate.go)
|
||||
out = slices.DeleteFunc(out, func(p Provider) bool { return Moved(p.ID) })
|
||||
return append(out, pluginAccounts()...)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,759 @@
|
||||
package provider
|
||||
|
||||
// Retiring a built-in subscription. Each subscription with a mover below
|
||||
// has a community plugin that does what the built-in does
|
||||
// (github.com/magpie-community/plugins). Moving one puts its accounts onto
|
||||
// the plugin under the same id, so agents on zed/<model>, the model picks,
|
||||
// routing and fallbacks carry on as they were:
|
||||
//
|
||||
// 1. the plugin is installed, if it isn't;
|
||||
// 2. each account's sign-in is kept as the plugin keeps one, in the order
|
||||
// and on or off as the user had it;
|
||||
// 3. each is tried as a request would try it (the plugin's loader and its
|
||||
// model list for that account), and every model the built-in has in use
|
||||
// must be one the plugin serves;
|
||||
// 4. only then does the id change hands: the built-in's saved accounts go
|
||||
// out of logins.json into migrations.json, kept for going back.
|
||||
//
|
||||
// Anything short of that puts everything back as it was — the plugin's
|
||||
// newer tokens included, since a refresh token used once is spent — and
|
||||
// the built-in carries on. MoveBack undoes a move the same way.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/yetone/magpie/internal/filememo"
|
||||
"github.com/yetone/magpie/internal/plugin"
|
||||
"github.com/yetone/magpie/internal/update"
|
||||
)
|
||||
|
||||
// A migration's states.
|
||||
const (
|
||||
MoveMoving = "moving" // under way, or cut short (put back at the next try)
|
||||
MovePlugin = "plugin" // the plugin has the id
|
||||
MovedBack = "back" // the user moved it back: it stays built-in
|
||||
MoveFailed = "failed" // the last try failed: the built-in carries on
|
||||
)
|
||||
|
||||
// Retiring are the built-ins magpie moves onto their plugins by itself,
|
||||
// at start-up; the rest move only when the user asks.
|
||||
var Retiring = []string{}
|
||||
|
||||
// moveRetry is how long a failed move waits before it is tried again.
|
||||
const moveRetry = 6 * time.Hour
|
||||
|
||||
// Migration is where one built-in's move stands.
|
||||
type Migration struct {
|
||||
State string `json:"state"`
|
||||
Package string `json:"package,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
Err string `json:"error,omitempty"`
|
||||
// Accounts are the plugin's accounts the move made or took over.
|
||||
Accounts []movedAccount `json:"accounts,omitempty"`
|
||||
// Backup is the built-in's accounts as logins.json kept them.
|
||||
Backup []savedLogin `json:"backup,omitempty"`
|
||||
// Kept is what else of the built-in's the move set aside (Kiro's key).
|
||||
Kept json.RawMessage `json:"kept,omitempty"`
|
||||
}
|
||||
|
||||
type movedAccount struct {
|
||||
Key string `json:"key"`
|
||||
User string `json:"user"`
|
||||
Own bool `json:"own,omitempty"`
|
||||
// Was is what the plugin kept at Key before, when the user had signed
|
||||
// in to the same account through the plugin already.
|
||||
Was map[string]any `json:"was,omitempty"`
|
||||
// Sent is (a hash of) the sign-in given to the plugin: a plugin still
|
||||
// holding it renewed nothing, so the built-in's own is as new.
|
||||
Sent string `json:"sent,omitempty"`
|
||||
}
|
||||
|
||||
// signinHash tells two sign-ins apart without keeping either.
|
||||
func signinHash(a map[string]any) string {
|
||||
h := sha256.Sum256([]byte(jsonText(a)))
|
||||
return hex.EncodeToString(h[:8])
|
||||
}
|
||||
|
||||
// renewedSince are the built-in's accounts whose sign-in isn't the one
|
||||
// sent: the built-in renewed them while the move went on (a request, a
|
||||
// refresh) and, a refresh token being spent once used, the plugin's copy
|
||||
// may be dead.
|
||||
func renewedSince(mv *mover, moved []movedAccount) []string {
|
||||
now, err := mv.out()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, ma := range moved {
|
||||
if ma.Own {
|
||||
continue
|
||||
}
|
||||
for _, a := range now {
|
||||
if strings.EqualFold(a.User, ma.User) && signinHash(a.Auth) != ma.Sent {
|
||||
out = append(out, ma.User)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Moving is one of a built-in's accounts on its way to the plugin.
|
||||
type Moving struct {
|
||||
User string
|
||||
First, On bool
|
||||
// Lapsed is an account the vendor already refused: it goes along, but
|
||||
// isn't tried.
|
||||
Lapsed bool
|
||||
// Own is the agent's own sign-in (its CLI's, its app's), which the
|
||||
// plugin reads where the agent keeps it, as the built-in did.
|
||||
Own bool
|
||||
Auth map[string]any
|
||||
}
|
||||
|
||||
// A mover carries one built-in's accounts to its plugin and back.
|
||||
type mover struct {
|
||||
pkg string
|
||||
// min is the oldest version of pkg that runs the accounts as moved
|
||||
min string
|
||||
// agents are the logins.json agents its accounts are saved under.
|
||||
agents []string
|
||||
// out is the built-in's accounts as the plugin's sign-ins, the one in
|
||||
// use first.
|
||||
out func() ([]Moving, error)
|
||||
// back puts one of the plugin's sign-ins back into the built-in's
|
||||
// saved accounts: into the one it came from (user), else a new one.
|
||||
// It gives the accounts and the one it wrote to.
|
||||
back func(ls []savedLogin, user string, auth map[string]any) ([]savedLogin, string, error)
|
||||
// take and give set aside what else of the built-in's the plugin now
|
||||
// has, and put it back.
|
||||
take func() (json.RawMessage, error)
|
||||
give func(json.RawMessage) error
|
||||
}
|
||||
|
||||
var movers = map[string]*mover{}
|
||||
|
||||
// errStays is a back's answer for a sign-in the built-in has nowhere to
|
||||
// keep (one made in the plugin's own way): it stays with the plugin, which
|
||||
// lists it under its own id once the built-in has the id again.
|
||||
var errStays = errors.New("stays with the plugin")
|
||||
|
||||
// Movable is whether the built-in id has a plugin it can move to.
|
||||
func Movable(id string) bool { return movers[id] != nil }
|
||||
|
||||
// MovePackage is the plugin the built-in id moves to.
|
||||
func MovePackage(id string) string {
|
||||
if m := movers[id]; m != nil {
|
||||
return m.pkg
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func migrationsPath() string { return filepath.Join(filepath.Dir(Path()), "migrations.json") }
|
||||
|
||||
var migrationsMu sync.Mutex
|
||||
|
||||
func readMigrations() map[string]Migration {
|
||||
m, _ := filememo.Read("migrations", migrationsPath(), func(b []byte) (map[string]Migration, error) {
|
||||
var m map[string]Migration
|
||||
_ = json.Unmarshal(b, &m)
|
||||
return m, nil
|
||||
})
|
||||
return m
|
||||
}
|
||||
|
||||
// MigrationOf is where the built-in id's move stands.
|
||||
func MigrationOf(id string) (Migration, bool) {
|
||||
m, ok := readMigrations()[id]
|
||||
return m, ok
|
||||
}
|
||||
|
||||
// Moved is whether the built-in id's accounts are its plugin's now.
|
||||
func Moved(id string) bool {
|
||||
m, ok := MigrationOf(id)
|
||||
return ok && m.State == MovePlugin
|
||||
}
|
||||
|
||||
// OnPlugins are the built-ins moved onto their plugins.
|
||||
func OnPlugins() []string {
|
||||
var out []string
|
||||
for id, m := range readMigrations() {
|
||||
if m.State == MovePlugin && movers[id] != nil {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func movingNow(id string) bool {
|
||||
m, ok := MigrationOf(id)
|
||||
return ok && m.State == MoveMoving
|
||||
}
|
||||
|
||||
func setMigration(id string, f func(m *Migration)) error {
|
||||
migrationsMu.Lock()
|
||||
defer migrationsMu.Unlock()
|
||||
all := map[string]Migration{}
|
||||
for k, v := range readMigrations() {
|
||||
all[k] = v
|
||||
}
|
||||
m := all[id]
|
||||
f(&m)
|
||||
all[id] = m
|
||||
b, err := json.MarshalIndent(all, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writePrivate(migrationsPath(), append(b, '\n'))
|
||||
}
|
||||
|
||||
// lockMoves keeps two magpies (the app and a CLI, a dev build beside a
|
||||
// release) from moving at once.
|
||||
func lockMoves() (func(), error) {
|
||||
p := migrationsPath() + ".lock"
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for range 2 {
|
||||
f, err := os.OpenFile(p, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
if err == nil {
|
||||
f.Close()
|
||||
return func() { os.Remove(p) }, nil
|
||||
}
|
||||
if fi, serr := os.Stat(p); serr == nil && time.Since(fi.ModTime()) > 15*time.Minute {
|
||||
os.Remove(p) // left by a magpie that died moving
|
||||
continue
|
||||
}
|
||||
return nil, errors.New("another magpie is moving subscriptions to plugins")
|
||||
}
|
||||
return nil, errors.New("another magpie is moving subscriptions to plugins")
|
||||
}
|
||||
|
||||
// Hooks the tests stand in for.
|
||||
var (
|
||||
installPlugin = func(ctx context.Context, pkg, min string) error {
|
||||
for _, e := range plugin.Load().Plugins {
|
||||
if plugin.PackageName(e.Spec) != pkg {
|
||||
continue
|
||||
}
|
||||
if e.Off {
|
||||
return fmt.Errorf("%s is turned off in Plugins", pkg)
|
||||
}
|
||||
v := plugin.Version(e.Spec)
|
||||
if min == "" || !update.Newer(min, v) {
|
||||
return nil
|
||||
}
|
||||
if plugin.IsPath(e.Spec) {
|
||||
return fmt.Errorf("%s at %s is %s; moving needs %s or newer", pkg, e.Spec, v, min)
|
||||
}
|
||||
break
|
||||
}
|
||||
_, err := plugin.Add(ctx, pkg)
|
||||
if err == nil && min != "" {
|
||||
if v := plugin.Version(pkg); update.Newer(min, v) {
|
||||
return fmt.Errorf("%s %s is installed; moving needs %s or newer", pkg, v, min)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
pluginProviders = plugin.Providers
|
||||
)
|
||||
|
||||
// modelsInUse are the models the built-in id serves now: the ones agents,
|
||||
// picks and routing can name.
|
||||
var modelsInUse = func(id string) []string {
|
||||
for _, p := range All() {
|
||||
if p.ID == id && !p.IsPlugin() {
|
||||
var out []string
|
||||
for _, m := range p.Exposed() {
|
||||
out = append(out, m.ID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Move puts the built-in id's accounts onto its plugin (see above). An
|
||||
// error leaves the built-in as it was.
|
||||
func Move(ctx context.Context, id string) error {
|
||||
mv := movers[id]
|
||||
if mv == nil {
|
||||
return fmt.Errorf("%s has no plugin to move to", id)
|
||||
}
|
||||
unlock, err := lockMoves()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unlock()
|
||||
if Moved(id) {
|
||||
return nil
|
||||
}
|
||||
if movingNow(id) {
|
||||
putBack(ctx, id, mv, "")
|
||||
}
|
||||
err = move(ctx, id, mv)
|
||||
if err != nil {
|
||||
_ = setMigration(id, func(m *Migration) {
|
||||
*m = Migration{State: MoveFailed, Package: mv.pkg, At: time.Now().UTC().Truncate(time.Second), Err: err.Error()}
|
||||
})
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func move(ctx context.Context, id string, mv *mover) (err error) {
|
||||
inUse := modelsInUse(id)
|
||||
accts, err := mv.out()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(accts) == 0 {
|
||||
return fmt.Errorf("no %s account to move", id)
|
||||
}
|
||||
if err := installPlugin(ctx, mv.pkg, mv.min); err != nil {
|
||||
return fmt.Errorf("installing %s: %w", mv.pkg, err)
|
||||
}
|
||||
pps, err := pluginProviders(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var pp plugin.Provider
|
||||
for _, p := range pps {
|
||||
if p.ID == id {
|
||||
if plugin.PackageName(p.Spec) != mv.pkg {
|
||||
return fmt.Errorf("%s is served by another plugin, %s", id, p.Spec)
|
||||
}
|
||||
pp = p
|
||||
}
|
||||
}
|
||||
if pp.ID == "" {
|
||||
return fmt.Errorf("%s doesn't serve %s", mv.pkg, id)
|
||||
}
|
||||
before := plugin.Auths(id)
|
||||
if err := setMigration(id, func(m *Migration) {
|
||||
*m = Migration{State: MoveMoving, Package: mv.pkg, At: time.Now().UTC().Truncate(time.Second)}
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
putBack(ctx, id, mv, err.Error())
|
||||
}
|
||||
}()
|
||||
var moved []movedAccount
|
||||
for _, a := range accts {
|
||||
key, err := plugin.Import(ctx, id, a.Auth)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", a.User, err)
|
||||
}
|
||||
ma := movedAccount{Key: key, User: a.User, Own: a.Own, Was: before[key], Sent: signinHash(a.Auth)}
|
||||
moved = append(moved, ma)
|
||||
if err := setMigration(id, func(m *Migration) { m.Accounts = moved }); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := keepOrder(pp, accts, moved); err != nil {
|
||||
return err
|
||||
}
|
||||
tried := false
|
||||
for i, a := range accts {
|
||||
if a.Lapsed {
|
||||
continue
|
||||
}
|
||||
models, err := plugin.Check(ctx, id, moved[i].Key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s through the plugin: %w", a.User, err)
|
||||
}
|
||||
if !tried {
|
||||
if missing := slices.DeleteFunc(slices.Clone(inUse), func(m string) bool { return slices.Contains(models, m) }); len(missing) > 0 {
|
||||
return fmt.Errorf("the plugin doesn't serve %s", strings.Join(missing, ", "))
|
||||
}
|
||||
tried = true
|
||||
}
|
||||
}
|
||||
if !tried {
|
||||
return fmt.Errorf("every %s account needs signing in again", id)
|
||||
}
|
||||
return commitMove(id, mv, moved)
|
||||
}
|
||||
|
||||
// keepOrder lists the accounts moved as the built-in had them: the one in
|
||||
// use first, the rest on or off. What the user had signed in to through
|
||||
// the plugin already keeps its place.
|
||||
func keepOrder(pp plugin.Provider, accts []Moving, moved []movedAccount) error {
|
||||
agent := pluginAgent(pp)
|
||||
loginsMu.Lock()
|
||||
defer loginsMu.Unlock()
|
||||
ls := readLogins()
|
||||
first := ""
|
||||
for i, a := range accts {
|
||||
if a.First && moved[i].Was == nil {
|
||||
first = moved[i].Key
|
||||
}
|
||||
}
|
||||
for i, a := range accts {
|
||||
ma := moved[i]
|
||||
if ma.Was != nil {
|
||||
continue
|
||||
}
|
||||
ls = slices.DeleteFunc(ls, func(l savedLogin) bool { return l.Agent == agent && l.Home == ma.Key })
|
||||
ls = append(ls, savedLogin{Agent: agent, User: a.User, Home: ma.Key, On: a.On || a.First, First: ma.Key == first, Seen: time.Now().UTC().Truncate(time.Second)})
|
||||
}
|
||||
if first != "" {
|
||||
for i := range ls {
|
||||
if ls[i].Agent == agent && ls[i].Home != first {
|
||||
ls[i].First = false
|
||||
}
|
||||
}
|
||||
}
|
||||
return writeLogins(ls)
|
||||
}
|
||||
|
||||
// commitMove hands the id over: the built-in's accounts go aside.
|
||||
func commitMove(id string, mv *mover, moved []movedAccount) error {
|
||||
if r := renewedSince(mv, moved); len(r) > 0 {
|
||||
return fmt.Errorf("%s renewed while moving; tried again later", strings.Join(r, ", "))
|
||||
}
|
||||
var kept json.RawMessage
|
||||
if mv.take != nil {
|
||||
k, err := mv.take()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
kept = k
|
||||
}
|
||||
loginsMu.Lock()
|
||||
var backup []savedLogin
|
||||
for _, l := range readLogins() {
|
||||
if slices.Contains(mv.agents, l.Agent) {
|
||||
backup = append(backup, l)
|
||||
}
|
||||
}
|
||||
loginsMu.Unlock()
|
||||
if err := setMigration(id, func(m *Migration) {
|
||||
*m = Migration{State: MovePlugin, Package: mv.pkg, At: time.Now().UTC().Truncate(time.Second), Accounts: moved, Backup: backup, Kept: kept}
|
||||
}); err != nil {
|
||||
if mv.give != nil {
|
||||
_ = mv.give(kept)
|
||||
}
|
||||
return err
|
||||
}
|
||||
tidyMoved(id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// tidyMoved takes a moved built-in's accounts out of logins.json (into its
|
||||
// backup, if one was left behind when a magpie stopped between the two).
|
||||
func tidyMoved(id string) {
|
||||
mv := movers[id]
|
||||
if mv == nil || !Moved(id) {
|
||||
return
|
||||
}
|
||||
loginsMu.Lock()
|
||||
ls := readLogins()
|
||||
var left []savedLogin
|
||||
ls = slices.DeleteFunc(ls, func(l savedLogin) bool {
|
||||
if slices.Contains(mv.agents, l.Agent) {
|
||||
left = append(left, l)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
if len(left) > 0 {
|
||||
_ = writeLogins(ls)
|
||||
}
|
||||
loginsMu.Unlock()
|
||||
if len(left) == 0 {
|
||||
return
|
||||
}
|
||||
_ = setMigration(id, func(m *Migration) {
|
||||
for _, l := range left {
|
||||
if !slices.ContainsFunc(m.Backup, func(b savedLogin) bool { return sameMoved(b, l) }) {
|
||||
m.Backup = append(m.Backup, l)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func sameMoved(a, b savedLogin) bool {
|
||||
return a.Agent == b.Agent && strings.EqualFold(a.User, b.User) && a.Home == b.Home
|
||||
}
|
||||
|
||||
// putBack undoes a move cut short: the plugin's sign-ins go back into the
|
||||
// built-in's accounts (its newer tokens with them), then out of the
|
||||
// plugin — but for one the user had there already, which gets back what
|
||||
// it had.
|
||||
func putBack(ctx context.Context, id string, mv *mover, why string) {
|
||||
m, _ := MigrationOf(id)
|
||||
var keys []string
|
||||
for _, ma := range m.Accounts {
|
||||
keys = append(keys, ma.Key)
|
||||
}
|
||||
renewed := renewedSince(mv, m.Accounts)
|
||||
skip := func(ma movedAccount, a map[string]any) bool {
|
||||
return signinHash(a) == ma.Sent || slices.ContainsFunc(renewed, func(u string) bool { return strings.EqualFold(u, ma.User) })
|
||||
}
|
||||
_, _, _ = handBack(ctx, id, mv, m.Accounts, keys, skip, nil, func(ls []savedLogin, _ map[string]string) []savedLogin { return ls })
|
||||
for _, ma := range m.Accounts {
|
||||
if ma.Was != nil {
|
||||
_ = plugin.Restore(ctx, id, ma.Key, ma.Was)
|
||||
}
|
||||
}
|
||||
_ = setMigration(id, func(m *Migration) {
|
||||
*m = Migration{State: MoveFailed, Package: mv.pkg, At: time.Now().UTC().Truncate(time.Second), Err: why}
|
||||
})
|
||||
}
|
||||
|
||||
// handBack writes the plugin's accounts keys back into the built-in's
|
||||
// saved accounts, then takes them out of the plugin. They are written
|
||||
// before they are taken, so a magpie stopped between the two loses no
|
||||
// sign-in; one the plugin renewed in between is written again. arrange
|
||||
// finishes the accounts written (given each key's user). An account that
|
||||
// can't go back stops it all before anything is written.
|
||||
func handBack(ctx context.Context, id string, mv *mover, accts []movedAccount, keys []string, skip func(movedAccount, map[string]any) bool, prep, arrange func([]savedLogin, map[string]string) []savedLogin) (map[string]string, []error, error) {
|
||||
var stays []string
|
||||
byKey := map[string]movedAccount{}
|
||||
for _, ma := range accts {
|
||||
byKey[ma.Key] = ma
|
||||
}
|
||||
write := func(auths map[string]map[string]any) (map[string]string, []error, error) {
|
||||
loginsMu.Lock()
|
||||
defer loginsMu.Unlock()
|
||||
ls := readLogins()
|
||||
if prep != nil {
|
||||
ls = prep(ls, nil)
|
||||
}
|
||||
users := map[string]string{}
|
||||
var errs []error
|
||||
stays = nil
|
||||
for _, k := range keys {
|
||||
ma := byKey[k]
|
||||
if ma.Own {
|
||||
users[k] = ma.User
|
||||
continue
|
||||
}
|
||||
a := auths[k]
|
||||
if a == nil {
|
||||
continue
|
||||
}
|
||||
if skip != nil && skip(ma, a) {
|
||||
users[k] = ma.User
|
||||
continue
|
||||
}
|
||||
next, u, err := mv.back(ls, ma.User, a)
|
||||
if errors.Is(err, errStays) {
|
||||
stays = append(stays, k)
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
ls, users[k] = next, u
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return users, errs, nil
|
||||
}
|
||||
return users, nil, writeLogins(arrange(ls, users))
|
||||
}
|
||||
seen := plugin.Auths(id)
|
||||
users, errs, err := write(seen)
|
||||
if err != nil || len(errs) > 0 {
|
||||
return users, errs, err
|
||||
}
|
||||
keys = slices.DeleteFunc(slices.Clone(keys), func(k string) bool { return slices.Contains(stays, k) })
|
||||
if len(keys) == 0 {
|
||||
return users, nil, nil
|
||||
}
|
||||
taken, err := plugin.Take(ctx, id, keys)
|
||||
if err != nil {
|
||||
return users, nil, err
|
||||
}
|
||||
for _, k := range keys {
|
||||
if t := taken[k]; t != nil && jsonText(t) != jsonText(seen[k]) {
|
||||
return write(taken)
|
||||
}
|
||||
}
|
||||
return users, nil, nil
|
||||
}
|
||||
|
||||
// MoveBack gives the id back to the built-in: its accounts as they were
|
||||
// set aside, with what the plugin has of them now (newer tokens, accounts
|
||||
// signed in to since), in the plugin's order; then the plugin signs out.
|
||||
func MoveBack(ctx context.Context, id string) error {
|
||||
mv := movers[id]
|
||||
if mv == nil {
|
||||
return fmt.Errorf("%s isn't a built-in subscription", id)
|
||||
}
|
||||
unlock, err := lockMoves()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unlock()
|
||||
m, ok := MigrationOf(id)
|
||||
if !ok || m.State != MovePlugin {
|
||||
return nil
|
||||
}
|
||||
var order []plugin.Account
|
||||
if pp, ok := pluginOfAgent("plugin:" + id); ok {
|
||||
for _, l := range pluginLogins(pp) {
|
||||
order = append(order, l.acct)
|
||||
}
|
||||
}
|
||||
keys := slices.Collect(maps.Keys(plugin.Auths(id)))
|
||||
sort.SliceStable(keys, func(i, j int) bool {
|
||||
if a, b := indexOfKey(order, keys[i]), indexOfKey(order, keys[j]); a != b {
|
||||
return a < b
|
||||
}
|
||||
return keys[i] < keys[j]
|
||||
})
|
||||
onOf := pluginOn(id)
|
||||
// the backup first, for back to write into the accounts it came from
|
||||
prep := func(ls []savedLogin, _ map[string]string) []savedLogin {
|
||||
for _, b := range m.Backup {
|
||||
if !slices.ContainsFunc(ls, func(l savedLogin) bool { return sameMoved(l, b) }) {
|
||||
ls = append(ls, b)
|
||||
}
|
||||
}
|
||||
return ls
|
||||
}
|
||||
arrange := func(ls []savedLogin, users map[string]string) []savedLogin {
|
||||
firstUser := ""
|
||||
for i, k := range keys {
|
||||
user := users[k]
|
||||
if user == "" {
|
||||
continue
|
||||
}
|
||||
if firstUser == "" && i == 0 {
|
||||
firstUser = user
|
||||
}
|
||||
for j := range ls {
|
||||
if slices.Contains(mv.agents, ls[j].Agent) && strings.EqualFold(ls[j].User, user) {
|
||||
if on, ok := onOf[k]; ok {
|
||||
ls[j].On = on
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if firstUser != "" {
|
||||
for j := range ls {
|
||||
if slices.Contains(mv.agents, ls[j].Agent) {
|
||||
ls[j].First = strings.EqualFold(ls[j].User, firstUser) && !ls[j].own()
|
||||
}
|
||||
}
|
||||
}
|
||||
return ls
|
||||
}
|
||||
// the backup goes back even with no account left in the plugin
|
||||
if len(keys) == 0 {
|
||||
loginsMu.Lock()
|
||||
err := writeLogins(arrange(prep(readLogins(), nil), nil))
|
||||
loginsMu.Unlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
_, errs, err := handBack(ctx, id, mv, m.Accounts, keys, nil, prep, arrange)
|
||||
if err != nil {
|
||||
tidyMoved(id)
|
||||
return err
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
// the plugin keeps the id and every account
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
}
|
||||
if mv.give != nil && len(m.Kept) > 0 {
|
||||
if err := mv.give(m.Kept); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return setMigration(id, func(m *Migration) {
|
||||
*m = Migration{State: MovedBack, Package: mv.pkg, At: time.Now().UTC().Truncate(time.Second)}
|
||||
})
|
||||
}
|
||||
|
||||
func indexOfKey(order []plugin.Account, key string) int {
|
||||
if i := slices.IndexFunc(order, func(a plugin.Account) bool { return a.Key == key }); i >= 0 {
|
||||
return i
|
||||
}
|
||||
return len(order)
|
||||
}
|
||||
|
||||
// pluginOn is which of the plugin provider id's accounts are on.
|
||||
func pluginOn(id string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
loginsMu.Lock()
|
||||
defer loginsMu.Unlock()
|
||||
for _, l := range readLogins() {
|
||||
if l.Agent == "plugin:"+id {
|
||||
out[l.Home] = l.On || l.First
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MoveRetiring moves the built-ins being retired that have accounts, at
|
||||
// start-up: once, or again moveRetry after one failed. One the user moved
|
||||
// back stays.
|
||||
func MoveRetiring(ctx context.Context) map[string]error {
|
||||
out := map[string]error{}
|
||||
for _, id := range Retiring {
|
||||
m, ok := MigrationOf(id)
|
||||
switch {
|
||||
case ok && m.State == MovePlugin:
|
||||
tidyMoved(id)
|
||||
continue
|
||||
case ok && m.State == MovedBack:
|
||||
continue
|
||||
case ok && m.State == MoveFailed && time.Since(m.At) < moveRetry:
|
||||
continue
|
||||
}
|
||||
if accts, err := movers[id].out(); err != nil || len(accts) == 0 {
|
||||
continue
|
||||
}
|
||||
out[id] = Move(ctx, id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// KeepRetiringMoved moves the built-ins being retired, run by the magpie
|
||||
// serving the gateway (one magpie, never two at once): a little after it
|
||||
// starts, then every hour, so a failed move is tried again once moveRetry
|
||||
// has gone.
|
||||
func KeepRetiringMoved(ctx context.Context) {
|
||||
if len(Retiring) == 0 {
|
||||
return
|
||||
}
|
||||
t := time.NewTimer(20 * time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
for id, err := range MoveRetiring(ctx) {
|
||||
if err != nil {
|
||||
log.Printf("moving %s to its plugin: %s (it stays built-in)", id, err)
|
||||
} else {
|
||||
log.Printf("moved %s to its plugin, %s", id, MovePackage(id))
|
||||
}
|
||||
}
|
||||
t.Reset(time.Hour)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Factory's accounts on @magpie-community/opencode-factory-auth, as its
|
||||
// device sign-in keeps one: the WorkOS pair as access and refresh (the
|
||||
// refresh token rotates, so it moves rather than being copied), the org
|
||||
// and region droid needs beside them.
|
||||
func init() {
|
||||
movers["factory"] = &mover{
|
||||
pkg: "@magpie-community/opencode-factory-auth",
|
||||
agents: []string{"factory"},
|
||||
out: func() ([]Moving, error) {
|
||||
ls := factoryLogins()
|
||||
// no refresh of the built-in's runs while the pairs are read
|
||||
factoryMu.Lock()
|
||||
defer factoryMu.Unlock()
|
||||
var out []Moving
|
||||
for _, l := range ls {
|
||||
c := l.creds
|
||||
out = append(out, Moving{User: l.User, First: l.Active, On: l.On, Lapsed: l.Lapsed != "", Auth: map[string]any{
|
||||
"type": "oauth",
|
||||
"access": c.Access,
|
||||
"refresh": c.Refresh,
|
||||
"expires": c.ExpiresAt,
|
||||
"orgId": c.Org,
|
||||
"email": c.Email,
|
||||
"userId": c.UserID,
|
||||
"activeOrganizationId": c.Active,
|
||||
"region": c.Region,
|
||||
"premBaseHost": c.Prem,
|
||||
"accountId": l.User,
|
||||
}})
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
back: func(ls []savedLogin, user string, auth map[string]any) ([]savedLogin, string, error) {
|
||||
access := str(auth["access"])
|
||||
if access == "" {
|
||||
return ls, "", errors.New("Factory: an unreadable plugin sign-in")
|
||||
}
|
||||
if user == "" {
|
||||
user = firstNonEmpty(str(auth["accountId"]), str(auth["email"]), str(auth["userId"]))
|
||||
}
|
||||
i := backInto(&ls, "factory", user)
|
||||
c, _ := factorySaved(ls[i])
|
||||
c.Access, c.Refresh, c.ExpiresAt = access, str(auth["refresh"]), num(auth["expires"])
|
||||
c.Org = firstNonEmpty(str(auth["orgId"]), c.Org)
|
||||
c.Active = firstNonEmpty(str(auth["activeOrganizationId"]), c.Active)
|
||||
c.Email = firstNonEmpty(str(auth["email"]), c.Email)
|
||||
c.UserID = firstNonEmpty(str(auth["userId"]), c.UserID)
|
||||
c.Region = firstNonEmpty(str(auth["region"]), c.Region)
|
||||
c.Prem = firstNonEmpty(str(auth["premBaseHost"]), c.Prem)
|
||||
b, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
return ls, "", err
|
||||
}
|
||||
ls[i].Auth, ls[i].Lapsed, ls[i].Seen = b, "", time.Now().UTC().Truncate(time.Second)
|
||||
return ls, ls[i].User, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Kiro's accounts on @magpie-community/opencode-kiro-auth:
|
||||
// - kiro-cli's or the IDE's own sign-in goes as a marker ({source:
|
||||
// "kiro"}): the plugin reads it where they keep it, as the built-in did;
|
||||
// - an account magpie signed in (a home holding kiro-auth-token.json)
|
||||
// goes whole, its tokens the plugin's to refresh from then on; the
|
||||
// home stays, and moving back writes the plugin's tokens into it;
|
||||
// - a key saved on the provider goes as an API key account, first (with
|
||||
// a key, the built-in used nothing else), and comes off the provider
|
||||
// while the plugin has it.
|
||||
func init() {
|
||||
movers["kiro"] = &mover{
|
||||
pkg: "@magpie-community/opencode-kiro-auth",
|
||||
agents: []string{"kiro"},
|
||||
out: func() ([]Moving, error) {
|
||||
var out []Moving
|
||||
key := kiroKey()
|
||||
if key != "" {
|
||||
out = append(out, Moving{User: "Kiro API key", First: true, On: true, Auth: map[string]any{"type": "api", "key": key}})
|
||||
}
|
||||
for _, l := range kiroLogins() {
|
||||
m := Moving{User: l.User, First: l.Active && key == "", On: l.On, Lapsed: l.Lapsed != ""}
|
||||
if l.Home == "" {
|
||||
m.Own = true
|
||||
m.Auth = map[string]any{"type": "oauth", "source": "kiro", "access": "", "refresh": "", "expires": 0, "accountId": l.User}
|
||||
} else {
|
||||
a, err := kiroOut(l.Home, l.User, l.Plan)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.Auth = a
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
back: func(ls []savedLogin, user string, auth map[string]any) ([]savedLogin, string, error) {
|
||||
switch {
|
||||
case str(auth["type"]) == "api":
|
||||
// the key goes back onto the provider (give, or here for
|
||||
// one saved in the plugin since)
|
||||
if k := str(auth["key"]); k != "" && kiroKey() == "" {
|
||||
return ls, "", setKiroKey(k)
|
||||
}
|
||||
return ls, "", nil
|
||||
case str(auth["source"]) != "":
|
||||
// kiro-cli's or the IDE's: nothing of it was the plugin's
|
||||
return ls, firstNonEmpty(user, str(auth["accountId"])), nil
|
||||
}
|
||||
if str(auth["access"]) == "" {
|
||||
return ls, "", errors.New("Kiro: an unreadable plugin sign-in")
|
||||
}
|
||||
if user == "" {
|
||||
user = str(auth["accountId"])
|
||||
}
|
||||
i := backInto(&ls, "kiro", user)
|
||||
if ls[i].Home == "" {
|
||||
home, err := newKiroHome()
|
||||
if err != nil {
|
||||
return ls, "", err
|
||||
}
|
||||
ls[i].Home = home
|
||||
}
|
||||
s := kiroSaved{AccessToken: str(auth["access"]), RefreshToken: str(auth["refresh"]), AuthMethod: "social",
|
||||
Provider: str(auth["loginProvider"]), Region: str(auth["region"]), ProfileArn: str(auth["profileArn"]),
|
||||
ClientID: str(auth["clientId"]), ClientSecret: str(auth["clientSecret"])}
|
||||
if str(auth["method"]) == "idc" {
|
||||
s.AuthMethod = "IdC"
|
||||
}
|
||||
if ms, _ := auth["expires"].(float64); ms > 0 {
|
||||
s.ExpiresAt = time.UnixMilli(int64(ms)).UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
b, err := json.MarshalIndent(s, "", " ")
|
||||
if err != nil {
|
||||
return ls, "", err
|
||||
}
|
||||
if err := writePrivate(filepath.Join(ls[i].Home, kiroTokenFile), b); err != nil {
|
||||
return ls, "", err
|
||||
}
|
||||
if p := str(auth["plan"]); p != "" {
|
||||
ls[i].Plan = p
|
||||
}
|
||||
ls[i].Lapsed, ls[i].Seen = "", time.Now().UTC().Truncate(time.Second)
|
||||
kiroForgetHome(ls[i].Home)
|
||||
return ls, ls[i].User, nil
|
||||
},
|
||||
take: func() (json.RawMessage, error) {
|
||||
k := kiroKey()
|
||||
if k == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return json.RawMessage(jsonText(k)), setKiroKey("")
|
||||
},
|
||||
give: func(kept json.RawMessage) error {
|
||||
var k string
|
||||
if json.Unmarshal(kept, &k) != nil || k == "" || kiroKey() != "" {
|
||||
return nil
|
||||
}
|
||||
return setKiroKey(k)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// kiroOut is the account magpie signed in in home, as the plugin keeps one.
|
||||
func kiroOut(home, user, plan string) (map[string]any, error) {
|
||||
b, err := os.ReadFile(filepath.Join(home, kiroTokenFile))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var t kiroSaved
|
||||
if json.Unmarshal(b, &t) != nil || t.AccessToken == "" {
|
||||
return nil, errors.New("Kiro: " + user + "'s sign-in can't be read")
|
||||
}
|
||||
a := map[string]any{"type": "oauth", "access": t.AccessToken, "refresh": t.RefreshToken, "expires": 0,
|
||||
"method": "social", "loginProvider": t.Provider, "region": firstNonEmpty(t.Region, "us-east-1"),
|
||||
"profileArn": t.ProfileArn, "accountId": user}
|
||||
if e, err := time.Parse(time.RFC3339Nano, t.ExpiresAt); err == nil {
|
||||
a["expires"] = e.UnixMilli()
|
||||
}
|
||||
// as readKiroFile tells them apart
|
||||
if strings.EqualFold(t.AuthMethod, "IdC") && t.ClientID != "" {
|
||||
a["method"] = "idc"
|
||||
}
|
||||
if t.ClientID != "" {
|
||||
a["clientId"], a["clientSecret"] = t.ClientID, t.ClientSecret
|
||||
}
|
||||
if plan != "" {
|
||||
a["plan"] = plan
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// setKiroKey sets (or, "", clears) the key saved on the Kiro provider.
|
||||
func setKiroKey(k string) error {
|
||||
f := load()
|
||||
for i := range f.Providers {
|
||||
if f.Providers[i].ID == "kiro" {
|
||||
if f.Providers[i].Key == k {
|
||||
return nil
|
||||
}
|
||||
f.Providers[i].Key = k
|
||||
return store(f)
|
||||
}
|
||||
}
|
||||
if k == "" {
|
||||
return nil
|
||||
}
|
||||
f.Providers = append(f.Providers, Provider{ID: "kiro", Key: k})
|
||||
return store(f)
|
||||
}
|
||||
|
||||
// kiroForgetHome drops what was cached of home's sign-in, rewritten.
|
||||
func kiroForgetHome(home string) {
|
||||
kiroAuthCache.Lock()
|
||||
delete(kiroAuthCache.m, "\x00"+home)
|
||||
kiroAuthCache.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MiMo's accounts on @magpie-community/opencode-mimo-auth, as its sign-in
|
||||
// keeps one: the Xiaomi account (what signs it on again) as refresh, the
|
||||
// session's cookies as access, expiring a day after they were set, when
|
||||
// the app signs on again.
|
||||
func init() {
|
||||
movers[MiMoID] = &mover{
|
||||
pkg: "@magpie-community/opencode-mimo-auth",
|
||||
agents: []string{MiMoID},
|
||||
out: func() ([]Moving, error) {
|
||||
var out []Moving
|
||||
for _, l := range mimoLogins() {
|
||||
c := l.creds
|
||||
cookies := c.Cookies
|
||||
if cookies == nil {
|
||||
cookies = map[string]string{}
|
||||
}
|
||||
var expires int64
|
||||
if !c.Issued.IsZero() {
|
||||
expires = c.Issued.Add(24 * time.Hour).UnixMilli()
|
||||
}
|
||||
out = append(out, Moving{User: l.User, First: l.Active, On: l.On, Lapsed: l.Lapsed != "", Auth: map[string]any{
|
||||
"type": "oauth",
|
||||
"refresh": jsonText(map[string]any{"userId": c.UserID, "cUserId": c.CUserID, "passToken": c.PassToken, "deviceId": c.DeviceID, "region": c.Region, "base": c.Base}),
|
||||
"access": jsonText(cookies),
|
||||
"expires": expires,
|
||||
"accountId": c.UserID,
|
||||
}})
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
back: func(ls []savedLogin, user string, auth map[string]any) ([]savedLogin, string, error) {
|
||||
var r struct {
|
||||
UserID any `json:"userId"`
|
||||
CUserID any `json:"cUserId"`
|
||||
PassToken string `json:"passToken"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
Region string `json:"region"`
|
||||
Base string `json:"base"`
|
||||
}
|
||||
if json.Unmarshal([]byte(str(auth["refresh"])), &r) != nil || r.UserID == nil || r.PassToken == "" {
|
||||
return ls, "", errors.New("Xiaomi MiMo: an unreadable plugin sign-in")
|
||||
}
|
||||
var cookies map[string]string
|
||||
_ = json.Unmarshal([]byte(str(auth["access"])), &cookies)
|
||||
uid := idText(r.UserID)
|
||||
if user == "" {
|
||||
user = uid
|
||||
}
|
||||
i := backInto(&ls, MiMoID, user)
|
||||
c, _ := mimoSaved(ls[i])
|
||||
c.UserID, c.CUserID, c.PassToken = uid, firstNonEmpty(idText(r.CUserID), c.CUserID), r.PassToken
|
||||
c.DeviceID = firstNonEmpty(r.DeviceID, c.DeviceID)
|
||||
c.Region = firstNonEmpty(r.Region, c.Region)
|
||||
c.Base = firstNonEmpty(r.Base, c.Base)
|
||||
c.Cookies = cookies
|
||||
// the session was set a day before the plugin would sign on again
|
||||
if e := num(auth["expires"]); e > 0 {
|
||||
c.Issued = time.UnixMilli(e).Add(-24 * time.Hour).UTC()
|
||||
} else {
|
||||
c.Issued = time.Time{}
|
||||
}
|
||||
b, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
return ls, "", err
|
||||
}
|
||||
ls[i].Auth, ls[i].Lapsed, ls[i].Seen = b, "", time.Now().UTC().Truncate(time.Second)
|
||||
return ls, ls[i].User, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// idText is an id a plugin may keep as a number or a string.
|
||||
func idText(v any) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.Trim(jsonText(v), `"`)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// saveLogins writes logins.json as ls.
|
||||
func saveLogins(t *testing.T, ls ...savedLogin) {
|
||||
t.Helper()
|
||||
loginsMu.Lock()
|
||||
defer loginsMu.Unlock()
|
||||
if err := writeLogins(ls); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// loginOf is user's entry for agent in ls.
|
||||
func loginOf(t *testing.T, ls []savedLogin, agent, user string) savedLogin {
|
||||
t.Helper()
|
||||
for _, l := range ls {
|
||||
if l.Agent == agent && l.User == user {
|
||||
return l
|
||||
}
|
||||
}
|
||||
t.Fatalf("no %s login for %q in %+v", agent, user, ls)
|
||||
return savedLogin{}
|
||||
}
|
||||
|
||||
func movingOf(t *testing.T, id string) map[string]Moving {
|
||||
t.Helper()
|
||||
ms, err := movers[id].out()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := map[string]Moving{}
|
||||
for _, m := range ms {
|
||||
out[m.User] = m
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Kiro's own sign-in goes as a marker, an account magpie signed in whole,
|
||||
// the provider's key as an API key account (off the provider while the
|
||||
// plugin has it); back, a renewed sign-in is written into its home, one
|
||||
// signed in since gets a home, and the key goes back onto the provider.
|
||||
func TestKiroMover(t *testing.T) {
|
||||
kiroSandbox(t)
|
||||
writeFile(t, filepath.Join(kiroIDEDir(), "kiro-auth-token.json"), map[string]any{"accessToken": "ide-a", "refreshToken": "ide-r"})
|
||||
home, err := newKiroHome()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exp := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
writeFile(t, filepath.Join(home, kiroTokenFile), kiroSaved{AccessToken: "b-a", RefreshToken: "b-r", ExpiresAt: exp.Format(time.RFC3339Nano),
|
||||
AuthMethod: "IdC", Provider: "Enterprise", Region: "eu-west-1", ProfileArn: "arn:p", ClientID: "cid", ClientSecret: "cs"})
|
||||
saveLogins(t, savedLogin{Agent: "kiro", User: "b@x", Home: home, On: true, Plan: "Pro"})
|
||||
if err := setKiroKey("ksk_1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ms := movingOf(t, "kiro")
|
||||
if k := ms["Kiro API key"]; !k.First || k.Auth["type"] != "api" || k.Auth["key"] != "ksk_1" {
|
||||
t.Fatalf("the key: %+v", k)
|
||||
}
|
||||
own := ms["Kiro account"]
|
||||
if !own.Own || own.First || own.Auth["source"] != "kiro" || own.Auth["access"] != "" {
|
||||
t.Fatalf("Kiro's own: %+v", own)
|
||||
}
|
||||
b := ms["b@x"]
|
||||
if b.Own || b.First || !b.On || b.Auth["access"] != "b-a" || b.Auth["refresh"] != "b-r" || b.Auth["expires"] != exp.UnixMilli() ||
|
||||
b.Auth["method"] != "idc" || b.Auth["loginProvider"] != "Enterprise" || b.Auth["region"] != "eu-west-1" ||
|
||||
b.Auth["profileArn"] != "arn:p" || b.Auth["clientId"] != "cid" || b.Auth["clientSecret"] != "cs" || b.Auth["plan"] != "Pro" {
|
||||
t.Fatalf("b@x: %+v", b.Auth)
|
||||
}
|
||||
|
||||
kept, err := movers["kiro"].take()
|
||||
if err != nil || kiroKey() != "" {
|
||||
t.Fatalf("taking the key: %v, %q left", err, kiroKey())
|
||||
}
|
||||
|
||||
// the plugin's, as JSON gives them back
|
||||
var renewed map[string]any
|
||||
_ = json.Unmarshal([]byte(jsonText(b.Auth)), &renewed)
|
||||
renewed["access"], renewed["refresh"] = "b-a2", "b-r2"
|
||||
ls, user, err := movers["kiro"].back(readLogins(), "b@x", renewed)
|
||||
if err != nil || user != "b@x" || loginOf(t, ls, "kiro", "b@x").Home != home {
|
||||
t.Fatalf("back: %v %q %+v", err, user, ls)
|
||||
}
|
||||
n := len(ls)
|
||||
c, ok := readKiroFile(filepath.Join(home, kiroTokenFile))
|
||||
if !ok || c.access != "b-a2" || c.refresh != "b-r2" || !c.expires.Equal(exp) || c.method != "idc" || c.clientID != "cid" || c.profile != "arn:p" {
|
||||
t.Fatalf("b@x's home after going back: %+v", c)
|
||||
}
|
||||
|
||||
// one signed in through the plugin since: a home of its own
|
||||
ls, user, err = movers["kiro"].back(ls, "", map[string]any{"type": "oauth", "access": "n-a", "refresh": "n-r", "expires": float64(exp.UnixMilli()),
|
||||
"method": "social", "loginProvider": "Google", "accountId": "n@x"})
|
||||
nx := loginOf(t, ls, "kiro", "n@x")
|
||||
if err != nil || user != "n@x" || len(ls) != n+1 || nx.Home == "" || nx.Home == home {
|
||||
t.Fatalf("a new account back: %v %q %+v", err, user, ls)
|
||||
}
|
||||
if c, ok := readKiroFile(filepath.Join(nx.Home, kiroTokenFile)); !ok || c.access != "n-a" || !c.social {
|
||||
t.Fatalf("n@x's home: %+v", c)
|
||||
}
|
||||
// Kiro's own: nothing to write
|
||||
if ls2, user, err := movers["kiro"].back(ls, "Kiro account", own.Auth); err != nil || user != "Kiro account" || len(ls2) != n+1 {
|
||||
t.Fatalf("Kiro's own back: %v %q", err, user)
|
||||
}
|
||||
// the key: back onto the provider (give then leaves it)
|
||||
if _, _, err := movers["kiro"].back(ls, "", map[string]any{"type": "api", "key": "ksk_1"}); err != nil || kiroKey() != "ksk_1" {
|
||||
t.Fatalf("the key back: %v %q", err, kiroKey())
|
||||
}
|
||||
_ = setKiroKey("")
|
||||
if err := movers["kiro"].give(kept); err != nil || kiroKey() != "ksk_1" {
|
||||
t.Fatalf("giving the key back: %v %q", err, kiroKey())
|
||||
}
|
||||
}
|
||||
|
||||
// WorkBuddy desktop's sign-in goes as a marker the plugin reads the app's
|
||||
// file by, never as tokens; one magpie signed in goes whole, and comes back
|
||||
// with the plugin's renewed tokens.
|
||||
func TestWorkBuddyMover(t *testing.T) {
|
||||
home := signIn(t)
|
||||
wbTokens.Lock()
|
||||
wbTokens.m = map[string]wbCreds{}
|
||||
wbTokens.Unlock()
|
||||
wbWriteOwn(t, home, "own-access")
|
||||
two := wbCreds{UID: "u2", Access: "a2", Refresh: "r2", ExpiresAt: 1_900_000_000_000, RefreshExpiresAt: 1_950_000_000_000, Domain: "www.codebuddy.cn", TokenType: "Bearer"}
|
||||
saveLogins(t, savedLogin{Agent: "workbuddy", User: "Two", On: true, Auth: json.RawMessage(jsonText(two))})
|
||||
|
||||
ms := movingOf(t, "workbuddy")
|
||||
own := ms["Me"]
|
||||
if !own.Own || own.Auth["source"] != "desktop" || own.Auth["access"] != "" || own.Auth["refresh"] != "" || own.Auth["uid"] != "u1" {
|
||||
t.Fatalf("desktop's: %+v", own)
|
||||
}
|
||||
b := ms["Two"]
|
||||
if b.Own || b.Auth["access"] != "a2" || b.Auth["refresh"] != "r2" || b.Auth["expires"] != two.ExpiresAt ||
|
||||
b.Auth["refreshExpiresAt"] != two.RefreshExpiresAt || b.Auth["uid"] != "u2" || b.Auth["domain"] != two.Domain || b.Auth["tokenType"] != "Bearer" {
|
||||
t.Fatalf("Two: %+v", b.Auth)
|
||||
}
|
||||
if len(movingOf(t, WorkBuddyAIID)) != 0 {
|
||||
t.Fatal("WorkBuddy AI has no account here")
|
||||
}
|
||||
|
||||
var renewed map[string]any
|
||||
_ = json.Unmarshal([]byte(jsonText(b.Auth)), &renewed)
|
||||
renewed["access"], renewed["refresh"], renewed["expires"] = "a3", "r3", float64(1_910_000_000_000)
|
||||
ls, user, err := movers["workbuddy"].back(readLogins(), "Two", renewed)
|
||||
if err != nil || user != "Two" {
|
||||
t.Fatalf("back: %v %q %+v", err, user, ls)
|
||||
}
|
||||
got, ok := wbSavedCreds(loginOf(t, ls, "workbuddy", "Two"))
|
||||
want := two
|
||||
want.Access, want.Refresh, want.ExpiresAt = "a3", "r3", 1_910_000_000_000
|
||||
if !ok || got != want {
|
||||
t.Fatalf("Two back: %+v, want %+v", got, want)
|
||||
}
|
||||
if ls2, user, err := movers["workbuddy"].back(ls, "Me", own.Auth); err != nil || user != "Me" || len(ls2) != len(ls) {
|
||||
t.Fatalf("desktop's back: %v %q %+v", err, user, ls2)
|
||||
}
|
||||
}
|
||||
|
||||
// ZCode's accounts go as the plugin's sign-in keeps them and come back as
|
||||
// they were; ZCode's own goes as a copy while it has a key, and stops the
|
||||
// move when it has only ZCode's session, which a copy can't follow.
|
||||
func TestZCodeMover(t *testing.T) {
|
||||
home := signIn(t)
|
||||
t.Setenv("ZCODE_CREDENTIAL_SECRET", "test-secret")
|
||||
creds := filepath.Join(home, ".zcode", "v2", "credentials.json")
|
||||
writeFile(t, creds, map[string]any{
|
||||
"oauth:zai:user_info": zcodeEncrypt(t, `{"user_id":"u1","email":"own@example.com"}`),
|
||||
"account-provider:coding-plan:account:zai-individual-coding-plan:account:abc:api-key": zcodeEncrypt(t, "own.secret"),
|
||||
})
|
||||
team := zcodeKey{Base: ZCodeBigModelBase, Token: "biz", Org: "o1", Project: "p1", Key: "team.key"}
|
||||
saveLogins(t, savedLogin{Agent: "zcode", User: "team@x", Plan: "Team", On: true, Auth: json.RawMessage(jsonText(team))})
|
||||
|
||||
ms := movingOf(t, "zcode")
|
||||
own := ms["own@example.com"]
|
||||
var st map[string]any
|
||||
if !own.Own || own.Auth["access"] != "own.secret" || json.Unmarshal([]byte(str(own.Auth["refresh"])), &st) != nil ||
|
||||
st["site"] != "zai" || st["key"] != "own.secret" || st["device"] != zcodeDeviceMid() || st["base"] != ZCodeZaiBase {
|
||||
t.Fatalf("ZCode's own: %+v %+v", own, st)
|
||||
}
|
||||
tm := ms["team@x"]
|
||||
if json.Unmarshal([]byte(str(tm.Auth["refresh"])), &st) != nil || st["site"] != "bigmodel" || st["org"] != "o1" ||
|
||||
st["project"] != "p1" || st["token"] != "biz" || st["plan"] != "Team" || tm.Auth["expires"] != int64(0) {
|
||||
t.Fatalf("team@x: %+v %+v", tm.Auth, st)
|
||||
}
|
||||
|
||||
var back map[string]any
|
||||
_ = json.Unmarshal([]byte(jsonText(tm.Auth)), &back)
|
||||
ls, user, err := movers["zcode"].back(readLogins(), "team@x", back)
|
||||
if err != nil || user != "team@x" {
|
||||
t.Fatalf("back: %v %q %+v", err, user, ls)
|
||||
}
|
||||
if tl := loginOf(t, ls, "zcode", "team@x"); tl.Plan != "Team" {
|
||||
t.Fatalf("team@x back: %+v", tl)
|
||||
} else if k, ok := zcodeSaved(tl); !ok || k != team {
|
||||
t.Fatalf("team@x back: %+v", k)
|
||||
}
|
||||
// a key saved in the plugin since
|
||||
ls, user, err = movers["zcode"].back(ls, "", map[string]any{"type": "api", "key": "k.2", "metadata": map[string]any{"site": "bigmodel"}})
|
||||
if k, _ := zcodeSaved(loginOf(t, ls, "zcode", "ZCode")); err != nil || user != "ZCode" || k.Key != "k.2" || k.Base != ZCodeBigModelBase {
|
||||
t.Fatalf("a key back: %v %q %+v", err, user, k)
|
||||
}
|
||||
|
||||
// ZCode's session alone
|
||||
writeFile(t, creds, map[string]any{
|
||||
"oauth:zai:user_info": zcodeEncrypt(t, `{"user_id":"u1","email":"own@example.com"}`),
|
||||
"zcodejwttoken": zcodeEncrypt(t, zcodeTestJWT(time.Now().Add(time.Hour))),
|
||||
})
|
||||
if _, err := movers["zcode"].out(); err == nil {
|
||||
t.Fatal("ZCode's session alone moved as a copy")
|
||||
}
|
||||
os.Remove(creds)
|
||||
if ms := movingOf(t, "zcode"); len(ms) != 1 {
|
||||
t.Fatalf("with ZCode signed out: %+v", ms)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/yetone/magpie/internal/qoder"
|
||||
)
|
||||
|
||||
// roundTrip moves id's saved accounts out as the plugin keeps them and back
|
||||
// again, through JSON as plugin-auth.json has them: each account's sign-in
|
||||
// comes back as it was, the one in use first and the rest on or off as
|
||||
// they were. Put back where nothing is saved, each is an account again.
|
||||
func roundTrip(t *testing.T, id string, saved []savedLogin, same func(t *testing.T, a, b savedLogin)) {
|
||||
t.Helper()
|
||||
claudeHome(t)
|
||||
mv := movers[id]
|
||||
loginsMu.Lock()
|
||||
if err := writeLogins(slicesClone(saved)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loginsMu.Unlock()
|
||||
accts, err := mv.out()
|
||||
if err != nil || len(accts) != len(saved) {
|
||||
t.Fatalf("%s out: %d accounts, %v", id, len(accts), err)
|
||||
}
|
||||
for i, a := range accts {
|
||||
if a.User != saved[i].User || a.First != saved[i].First || a.On != (saved[i].On || saved[i].First) || a.Own {
|
||||
t.Fatalf("%s account %d: %+v", id, i, a)
|
||||
}
|
||||
// as the host writes it and Go reads it back
|
||||
var auth map[string]any
|
||||
if err := json.Unmarshal([]byte(jsonText(a.Auth)), &auth); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
accts[i].Auth = auth
|
||||
}
|
||||
for _, fresh := range []bool{false, true} {
|
||||
ls := slicesClone(saved)
|
||||
if fresh {
|
||||
ls = nil
|
||||
}
|
||||
for i, a := range accts {
|
||||
user := a.User
|
||||
if fresh {
|
||||
user = ""
|
||||
}
|
||||
next, u, err := mv.back(ls, user, a.Auth)
|
||||
if err != nil {
|
||||
t.Fatalf("%s back %s: %v", id, a.User, err)
|
||||
}
|
||||
ls = next
|
||||
if u != saved[i].User {
|
||||
t.Fatalf("%s back wrote %q, want %q", id, u, saved[i].User)
|
||||
}
|
||||
}
|
||||
if len(ls) != len(saved) {
|
||||
t.Fatalf("%s back (fresh %v): %d accounts", id, fresh, len(ls))
|
||||
}
|
||||
for i := range saved {
|
||||
if ls[i].Lapsed != "" {
|
||||
t.Fatalf("%s back left %s lapsed", id, ls[i].User)
|
||||
}
|
||||
same(t, saved[i], ls[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func slicesClone(ls []savedLogin) []savedLogin { return append([]savedLogin(nil), ls...) }
|
||||
|
||||
func authOf(t *testing.T, v any) json.RawMessage {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestQoderMover(t *testing.T) {
|
||||
c1 := qoder.Credential{UID: "u1", Email: "a@q.com", Name: "A", Token: "t1", RefreshToken: "r1", DeviceToken: "d1", DeviceRefresh: "dr1", ExpiresAt: 1_900_000_000_123, MachineID: "m1", Models: json.RawMessage(`[{"id":"x"}]`)}
|
||||
c2 := qoder.Credential{UID: "u2", Token: "t2", RefreshToken: "r2", DeviceToken: "d2", ExpiresAt: 1_900_000_000_456, MachineID: "m2"}
|
||||
roundTrip(t, "qoder", []savedLogin{
|
||||
{Agent: "qoder", User: "a@q.com", First: true, On: true, Auth: authOf(t, c1), Lapsed: "expired"},
|
||||
{Agent: "qoder", User: "u2", Auth: authOf(t, c2)},
|
||||
}, func(t *testing.T, a, b savedLogin) {
|
||||
ca, _ := qoderSaved(a)
|
||||
cb, _ := qoderSaved(b)
|
||||
if string(cb.Models) == "" {
|
||||
cb.Models = ca.Models // a new entry: the model list is the built-in's own cache
|
||||
}
|
||||
if !reflect.DeepEqual(ca, cb) {
|
||||
t.Fatalf("qoder %s: %+v, want %+v", a.User, cb, ca)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFactoryMover(t *testing.T) {
|
||||
c1 := factoryCreds{Access: "a1", Refresh: "r1", ExpiresAt: 1_900_000_000_000, Org: "org_1", Active: "fo1", Email: "a@f.com", UserID: "user_1", Region: "eu", Prem: "llm.example.com"}
|
||||
c2 := factoryCreds{Access: "a2", Refresh: "r2", ExpiresAt: 1_900_000_000_001, UserID: "user_2"}
|
||||
roundTrip(t, "factory", []savedLogin{
|
||||
{Agent: "factory", User: "user_2", First: true, Auth: authOf(t, c2)},
|
||||
{Agent: "factory", User: "a@f.com", On: true, Auth: authOf(t, c1)},
|
||||
}, func(t *testing.T, a, b savedLogin) {
|
||||
ca, _ := factorySaved(a)
|
||||
cb, _ := factorySaved(b)
|
||||
if ca != cb {
|
||||
t.Fatalf("factory %s: %+v, want %+v", a.User, cb, ca)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMiMoMover(t *testing.T) {
|
||||
issued := time.Date(2026, 9, 29, 10, 0, 0, 0, time.UTC)
|
||||
c1 := mimoCreds{UserID: "123", CUserID: "c123", PassToken: "p1", DeviceID: "dev1", Region: "sg", Base: "https://aistudio.xiaomimimo.com", Cookies: map[string]string{"serviceToken": "s1", "userId": "123"}, Issued: issued, Name: "Mi One"}
|
||||
c2 := mimoCreds{UserID: "456", PassToken: "p2", DeviceID: "dev2", Base: "https://aistudio.xiaomimimo.com", Cookies: map[string]string{"serviceToken": "s2"}, Issued: issued.Add(time.Hour)}
|
||||
roundTrip(t, MiMoID, []savedLogin{
|
||||
{Agent: MiMoID, User: "123", First: true, On: true, Auth: authOf(t, c1)},
|
||||
{Agent: MiMoID, User: "456", On: true, Auth: authOf(t, c2)},
|
||||
}, func(t *testing.T, a, b savedLogin) {
|
||||
ca, _ := mimoSaved(a)
|
||||
cb, _ := mimoSaved(b)
|
||||
if cb.Name == "" {
|
||||
cb.Name = ca.Name // a new entry: the name is only the built-in's label
|
||||
}
|
||||
if !reflect.DeepEqual(ca, cb) {
|
||||
t.Fatalf("mimo %s: %+v, want %+v", a.User, cb, ca)
|
||||
}
|
||||
})
|
||||
// the plugin keeps a numeric id as a number
|
||||
ls, u, err := movers[MiMoID].back(nil, "", map[string]any{"type": "oauth", "refresh": `{"userId":789,"passToken":"p","base":"https://b"}`, "access": `{}`, "expires": float64(issued.UnixMilli())})
|
||||
if c, ok := mimoSaved(ls[0]); err != nil || u != "789" || !ok || c.UserID != "789" {
|
||||
t.Fatalf("numeric id: %q %+v %v", u, ls, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Qoder's accounts on @magpie-community/opencode-qoder-auth, as its device
|
||||
// sign-in keeps one: the job token pair as access and refresh (the refresh
|
||||
// token is spent on use, so it moves rather than being copied), the device
|
||||
// token pair and machine id beside them.
|
||||
func init() {
|
||||
movers["qoder"] = &mover{
|
||||
pkg: "@magpie-community/opencode-qoder-auth",
|
||||
agents: []string{"qoder"},
|
||||
out: func() ([]Moving, error) {
|
||||
// no refresh of the built-in's runs while the pairs are read
|
||||
ls := qoderLogins()
|
||||
qoderMu.Lock()
|
||||
defer qoderMu.Unlock()
|
||||
var out []Moving
|
||||
for _, l := range ls {
|
||||
c, ok, _ := qoderCurrent(l.saved)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, Moving{User: l.User, First: l.Active, On: l.On, Lapsed: l.Lapsed != "", Auth: map[string]any{
|
||||
"type": "oauth",
|
||||
"access": c.Token,
|
||||
"refresh": c.RefreshToken,
|
||||
"expires": c.ExpiresAt,
|
||||
"accountId": l.User,
|
||||
"uid": c.UID,
|
||||
"email": c.Email,
|
||||
"name": c.Name,
|
||||
"machineId": c.MachineID,
|
||||
"deviceToken": c.DeviceToken,
|
||||
"deviceRefresh": c.DeviceRefresh,
|
||||
}})
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
back: func(ls []savedLogin, user string, auth map[string]any) ([]savedLogin, string, error) {
|
||||
access, uid := str(auth["access"]), str(auth["uid"])
|
||||
if access == "" || uid == "" {
|
||||
return ls, "", errors.New("Qoder: an unreadable plugin sign-in")
|
||||
}
|
||||
if user == "" {
|
||||
user = firstNonEmpty(str(auth["accountId"]), str(auth["email"]), uid)
|
||||
}
|
||||
i := backInto(&ls, "qoder", user)
|
||||
c, _ := qoderSaved(ls[i])
|
||||
c.UID, c.Token, c.RefreshToken, c.ExpiresAt = uid, access, str(auth["refresh"]), num(auth["expires"])
|
||||
c.Email = firstNonEmpty(str(auth["email"]), c.Email)
|
||||
c.Name = firstNonEmpty(str(auth["name"]), c.Name)
|
||||
c.MachineID = firstNonEmpty(str(auth["machineId"]), c.MachineID)
|
||||
c.DeviceToken = firstNonEmpty(str(auth["deviceToken"]), c.DeviceToken)
|
||||
c.DeviceRefresh = firstNonEmpty(str(auth["deviceRefresh"]), c.DeviceRefresh)
|
||||
b, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
return ls, "", err
|
||||
}
|
||||
ls[i].Auth, ls[i].Lapsed, ls[i].Seen = b, "", time.Now().UTC().Truncate(time.Second)
|
||||
return ls, ls[i].User, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// num is a JSON number read into any (a float64, or an int64 before it
|
||||
// was written out) as an int64.
|
||||
func num(v any) int64 {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int64(n)
|
||||
case int64:
|
||||
return n
|
||||
case int:
|
||||
return int64(n)
|
||||
case json.Number:
|
||||
i, _ := n.Int64()
|
||||
return i
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package provider
|
||||
|
||||
// Movers for the subscriptions whose agent keeps an account of its own
|
||||
// (Devin, Grok, Command Code, Cursor): the plugin takes that one the way
|
||||
// its own "CLI's sign-in" method does, reading or copying what the CLI
|
||||
// keeps, and the built-in keeps reading it after a move back; magpie's
|
||||
// further accounts go over as the plugin keeps a sign-in.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
movers["devin"] = &mover{
|
||||
pkg: "@magpie-community/opencode-devin-auth",
|
||||
agents: []string{"devin"},
|
||||
out: func() ([]Moving, error) {
|
||||
var out []Moving
|
||||
for _, l := range devinLogins() {
|
||||
key, server, err := DevinAuthAt(l.Home)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
md := map[string]any{"email": l.User}
|
||||
if l.Plan != "" {
|
||||
md["plan"] = l.Plan
|
||||
}
|
||||
if s := strings.TrimRight(server, "/"); s != "" && s != devinServer {
|
||||
md["server"] = s
|
||||
}
|
||||
out = append(out, Moving{User: l.User, First: l.Active, On: l.On, Lapsed: l.Lapsed != "", Own: l.Home == "",
|
||||
Auth: map[string]any{"type": "api", "key": key, "metadata": md}})
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
back: func(ls []savedLogin, user string, auth map[string]any) ([]savedLogin, string, error) {
|
||||
key := str(auth["key"])
|
||||
md, _ := auth["metadata"].(map[string]any)
|
||||
if key == "" {
|
||||
return ls, "", errors.New("Devin: an unreadable plugin sign-in")
|
||||
}
|
||||
if own, _, err := DevinAuthAt(""); err == nil && own == key {
|
||||
return ls, ownUser(ls, "devin", str(md["email"])), nil
|
||||
}
|
||||
if user == "" {
|
||||
user = str(md["email"])
|
||||
}
|
||||
if user == "" {
|
||||
return ls, "", errors.New("Devin: a plugin sign-in with no account")
|
||||
}
|
||||
i := backInto(&ls, "devin", user)
|
||||
home := ls[i].Home
|
||||
if home == "" {
|
||||
// the home the account had before the move, which it still has
|
||||
home = devinHomeWith(key)
|
||||
}
|
||||
if k, _, err := DevinAuthAt(home); home == "" || err != nil || k != key {
|
||||
if home == "" {
|
||||
h, err := newDevinHome()
|
||||
if err != nil {
|
||||
return ls, "", err
|
||||
}
|
||||
home = h
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(devinCredentialsAt(home)), 0o700); err != nil {
|
||||
return ls, "", err
|
||||
}
|
||||
if err := writePrivate(devinCredentialsAt(home), devinCredentials(key, str(md["server"]), "", "")); err != nil {
|
||||
return ls, "", err
|
||||
}
|
||||
}
|
||||
ls[i].Home, ls[i].Lapsed, ls[i].Seen = home, "", time.Now().UTC().Truncate(time.Second)
|
||||
if p := str(md["plan"]); p != "" {
|
||||
ls[i].Plan = p
|
||||
}
|
||||
return ls, ls[i].User, nil
|
||||
},
|
||||
}
|
||||
|
||||
// Grok's plugin reads the CLI's auth.json in the home its sign-in
|
||||
// names, as the built-in does: the homes stay where they are.
|
||||
movers["grok"] = &mover{
|
||||
pkg: "@magpie-community/opencode-grok-auth",
|
||||
agents: []string{"grok"},
|
||||
out: func() ([]Moving, error) {
|
||||
var out []Moving
|
||||
for _, l := range grokLogins() {
|
||||
c, ok := readGrokCredential(l.Home)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var exp int64
|
||||
if !c.ExpiresAt.IsZero() {
|
||||
exp = c.ExpiresAt.UnixMilli()
|
||||
}
|
||||
out = append(out, Moving{User: l.User, First: l.Active, On: l.On, Lapsed: l.Lapsed != "", Own: samePath(l.Home, GrokHome()),
|
||||
Auth: map[string]any{"type": "oauth", "refresh": l.Home, "access": c.Key, "expires": exp, "accountId": firstNonEmpty(c.Email, l.User)}})
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
back: func(ls []savedLogin, user string, auth map[string]any) ([]savedLogin, string, error) {
|
||||
home := str(auth["refresh"])
|
||||
if home == "" {
|
||||
return ls, "", errors.New("Grok: an unreadable plugin sign-in")
|
||||
}
|
||||
if samePath(home, GrokHome()) {
|
||||
return ls, ownUser(ls, "grok", str(auth["accountId"])), nil
|
||||
}
|
||||
if c, ok := readGrokCredential(home); ok && c.Email != "" {
|
||||
user = c.Email
|
||||
} else if user == "" {
|
||||
user = str(auth["accountId"])
|
||||
}
|
||||
if user == "" {
|
||||
return ls, "", errors.New("Grok: a plugin sign-in with no account")
|
||||
}
|
||||
i := backInto(&ls, "grok", user)
|
||||
ls[i].Home, ls[i].Lapsed, ls[i].Seen = home, "", time.Now().UTC().Truncate(time.Second)
|
||||
return ls, ls[i].User, nil
|
||||
},
|
||||
}
|
||||
|
||||
movers[CommandCodePlanID] = &mover{
|
||||
pkg: "@magpie-community/opencode-commandcode-auth",
|
||||
agents: []string{CommandCodePlanID},
|
||||
out: func() ([]Moving, error) {
|
||||
var out []Moving
|
||||
for _, l := range cmdLogins() {
|
||||
md := map[string]any{"email": l.User}
|
||||
if l.auth.UserID != "" {
|
||||
md["userId"] = l.auth.UserID
|
||||
}
|
||||
if l.auth.KeyName != "" {
|
||||
md["keyName"] = l.auth.KeyName
|
||||
}
|
||||
if l.Plan != "" {
|
||||
md["plan"] = l.Plan
|
||||
}
|
||||
out = append(out, Moving{User: l.User, First: l.Active, On: l.On, Lapsed: l.Lapsed != "", Own: l.Own,
|
||||
Auth: map[string]any{"type": "api", "key": l.auth.APIKey, "metadata": md}})
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
back: func(ls []savedLogin, user string, auth map[string]any) ([]savedLogin, string, error) {
|
||||
key := str(auth["key"])
|
||||
md, _ := auth["metadata"].(map[string]any)
|
||||
if key == "" {
|
||||
return ls, "", errors.New("Command Code: an unreadable plugin sign-in")
|
||||
}
|
||||
if _, own, ok := cmdOwn(); ok && own.APIKey == key {
|
||||
return ls, ownUser(ls, CommandCodePlanID, str(md["email"])), nil
|
||||
}
|
||||
i := -1
|
||||
// the same key signed in under another name is the same account
|
||||
for j, l := range ls {
|
||||
if a, ok := cmdSaved(l); ok && l.Agent == CommandCodePlanID && a.APIKey == key {
|
||||
i = j
|
||||
}
|
||||
}
|
||||
if i < 0 {
|
||||
if user == "" {
|
||||
user = str(md["email"])
|
||||
}
|
||||
if user == "" {
|
||||
return ls, "", errors.New("Command Code: a plugin sign-in with no account")
|
||||
}
|
||||
i = backInto(&ls, CommandCodePlanID, user)
|
||||
}
|
||||
a, _ := cmdSaved(ls[i])
|
||||
a.APIKey = key
|
||||
if v := str(md["userId"]); v != "" {
|
||||
a.UserID = v
|
||||
}
|
||||
if v := str(md["keyName"]); v != "" {
|
||||
a.KeyName = v
|
||||
}
|
||||
if a.UserName == "" && a.UserID != ls[i].User {
|
||||
a.UserName = ls[i].User
|
||||
}
|
||||
ls[i].Auth, ls[i].Lapsed, ls[i].Seen = []byte(jsonText(a)), "", time.Now().UTC().Truncate(time.Second)
|
||||
if p := str(md["plan"]); p != "" {
|
||||
ls[i].Plan = p
|
||||
}
|
||||
return ls, ls[i].User, nil
|
||||
},
|
||||
}
|
||||
|
||||
// Cursor's one account is cursor-agent's own, which magpie never keeps:
|
||||
// the plugin reads cursor-agent's token as the built-in does.
|
||||
movers["cursor"] = &mover{
|
||||
pkg: "@magpie-community/opencode-cursor-auth",
|
||||
agents: []string{"cursor"},
|
||||
out: func() ([]Moving, error) {
|
||||
if CursorExecutable() == "" || cursorSignedOut() {
|
||||
return nil, nil
|
||||
}
|
||||
user, plan, ok := cursorIdentity()
|
||||
if !ok || user == "" {
|
||||
return nil, nil
|
||||
}
|
||||
a := map[string]any{"type": "oauth", "access": "", "refresh": cursorCLIMark, "expires": 0, "accountId": user}
|
||||
if plan != "" {
|
||||
a["plan"] = plan
|
||||
}
|
||||
return []Moving{{User: user, First: true, On: true, Own: true, Auth: a}}, nil
|
||||
},
|
||||
back: func(ls []savedLogin, user string, auth map[string]any) ([]savedLogin, string, error) {
|
||||
if str(auth["refresh"]) == cursorCLIMark {
|
||||
return ls, firstNonEmpty(user, str(auth["accountId"])), nil
|
||||
}
|
||||
// the built-in has nowhere to keep a token of its own
|
||||
// signed in in the browser through the plugin: the built-in
|
||||
// Cursor has nowhere to keep it
|
||||
return ls, "", errStays
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
devinServer = "https://server.codeium.com"
|
||||
cursorCLIMark = "cursor-agent" // the Cursor plugin's sign-in that reads cursor-agent's token
|
||||
)
|
||||
|
||||
// ownUser is the account the agent's own sign-in is saved as, else user.
|
||||
func ownUser(ls []savedLogin, agent, user string) string {
|
||||
for _, l := range ls {
|
||||
if l.Agent == agent && l.own() && l.User != "" {
|
||||
return l.User
|
||||
}
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
func samePath(a, b string) bool {
|
||||
if a == "" || b == "" {
|
||||
return false
|
||||
}
|
||||
return filepath.Clean(a) == filepath.Clean(b)
|
||||
}
|
||||
|
||||
// devinHomeWith is the home of magpie's signed in with key, "" for none.
|
||||
func devinHomeWith(key string) string {
|
||||
es, _ := os.ReadDir(devinAccountsDir())
|
||||
for _, e := range es {
|
||||
home := filepath.Join(devinAccountsDir(), e.Name())
|
||||
if k, _, err := DevinAuthAt(home); e.IsDir() && err == nil && k == key {
|
||||
return home
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// moveAndBack does what Move and MoveBack do with a mover's accounts, but
|
||||
// for the plugin in between: the accounts go out, the built-in's saved ones
|
||||
// go aside, extra (sign-ins made through the plugin since) join them, and
|
||||
// every one comes back. It gives the accounts that went out and the saved
|
||||
// accounts before and after.
|
||||
func moveAndBack(t *testing.T, id string, extra ...map[string]any) (out []Moving, before, after []savedLogin) {
|
||||
t.Helper()
|
||||
mv := movers[id]
|
||||
out, err := mv.out()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ls := readLogins()
|
||||
before = slices.Clone(ls)
|
||||
var backup []savedLogin
|
||||
ls = slices.DeleteFunc(ls, func(l savedLogin) bool {
|
||||
if slices.Contains(mv.agents, l.Agent) {
|
||||
backup = append(backup, l)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
if err := writeLogins(ls); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// through the plugin, which keeps its sign-ins as JSON
|
||||
var auths []map[string]any
|
||||
for _, a := range out {
|
||||
if !a.Own {
|
||||
auths = append(auths, roundJSON(t, a.Auth))
|
||||
}
|
||||
}
|
||||
ls = readLogins()
|
||||
users := map[int]string{}
|
||||
for i, a := range out {
|
||||
if a.Own {
|
||||
continue
|
||||
}
|
||||
next, u, err := mv.back(ls, a.User, auths[0])
|
||||
auths = auths[1:]
|
||||
if err != nil {
|
||||
t.Fatalf("%s back: %v", a.User, err)
|
||||
}
|
||||
ls, users[i] = next, u
|
||||
}
|
||||
for _, a := range extra {
|
||||
next, _, err := mv.back(ls, "", roundJSON(t, a))
|
||||
if err != nil {
|
||||
t.Fatalf("back %v: %v", a, err)
|
||||
}
|
||||
ls = next
|
||||
}
|
||||
for _, b := range backup {
|
||||
if !slices.ContainsFunc(ls, func(l savedLogin) bool { return sameMoved(l, b) }) {
|
||||
ls = append(ls, b)
|
||||
}
|
||||
}
|
||||
for i, a := range out {
|
||||
if u := users[i]; u != "" && !strings.EqualFold(u, a.User) {
|
||||
t.Errorf("%s went back as %s", a.User, u)
|
||||
}
|
||||
}
|
||||
if err := writeLogins(ls); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out, before, readLogins()
|
||||
}
|
||||
|
||||
func roundJSON(t *testing.T, a map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal([]byte(jsonText(a)), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func loginsOfAgent(ls []savedLogin, agent string) []savedLogin {
|
||||
return slices.DeleteFunc(slices.Clone(ls), func(l savedLogin) bool { return l.Agent != agent })
|
||||
}
|
||||
|
||||
func TestMoveDevin(t *testing.T) {
|
||||
home := claudeHome(t)
|
||||
data := filepath.Join(home, "data")
|
||||
t.Setenv("XDG_DATA_HOME", data)
|
||||
os.MkdirAll(filepath.Join(data, "devin"), 0o700)
|
||||
os.WriteFile(DevinCredentialsPath(), devinCredentials("key-own", "", "", ""), 0o600)
|
||||
exe := filepath.Join(home, "devin")
|
||||
os.WriteFile(exe, []byte("#!/bin/sh\n"+devinSigned+"\n"), 0o755)
|
||||
fakeDevin(t, exe)
|
||||
two, _ := newDevinHome()
|
||||
os.WriteFile(devinCredentialsAt(two), devinCredentials("key-two", "https://eu.codeium.com", "", ""), 0o600)
|
||||
writeLogins([]savedLogin{{Agent: "devin", User: "two@example.com", Plan: "Devin Max", Home: two, On: true, First: true}})
|
||||
|
||||
out, before, after := moveAndBack(t, "devin",
|
||||
map[string]any{"type": "api", "key": "key-own", "metadata": map[string]any{"email": "dev@example.com"}},
|
||||
map[string]any{"type": "api", "key": "key-three", "metadata": map[string]any{"email": "three@example.com", "plan": "Pro"}})
|
||||
if len(out) != 2 || out[0].User != "two@example.com" || !out[0].First || out[0].Own ||
|
||||
out[0].Auth["key"] != "key-two" || out[0].Auth["metadata"].(map[string]any)["server"] != "https://eu.codeium.com" ||
|
||||
out[0].Auth["metadata"].(map[string]any)["plan"] != "Devin Max" ||
|
||||
out[1].User != "dev@example.com" || !out[1].Own || out[1].Auth["key"] != "key-own" || out[1].Auth["metadata"].(map[string]any)["server"] != nil {
|
||||
t.Fatalf("out %+v", out)
|
||||
}
|
||||
ls := loginsOfAgent(after, "devin")
|
||||
if len(ls) != len(loginsOfAgent(before, "devin"))+1 {
|
||||
t.Fatalf("back %+v\nbefore %+v", ls, before)
|
||||
}
|
||||
for _, l := range ls {
|
||||
switch l.User {
|
||||
case "two@example.com":
|
||||
if l.Home != two { // the home it had, not a new one
|
||||
t.Errorf("two's home %s, was %s", l.Home, two)
|
||||
}
|
||||
case "dev@example.com":
|
||||
if !l.own() {
|
||||
t.Errorf("own %+v", l)
|
||||
}
|
||||
case "three@example.com":
|
||||
if k, _, err := DevinAuthAt(l.Home); err != nil || k != "key-three" || l.Plan != "Pro" || !l.On {
|
||||
t.Errorf("three %+v %q %v", l, k, err)
|
||||
}
|
||||
default:
|
||||
t.Errorf("unexpected %+v", l)
|
||||
}
|
||||
}
|
||||
if k, s, _ := DevinAuthAt(two); k != "key-two" || s != "https://eu.codeium.com" {
|
||||
t.Fatalf("two's credentials %s %s", k, s)
|
||||
}
|
||||
if b, _ := os.ReadFile(DevinCredentialsPath()); !strings.Contains(string(b), "key-own") {
|
||||
t.Fatalf("the CLI's own changed: %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveGrok(t *testing.T) {
|
||||
home := signIn(t)
|
||||
t.Setenv("GROK_HOME", filepath.Join(home, ".grok"))
|
||||
grokSignedIn(t, GrokHome(), "me@x.ai")
|
||||
extra, _ := newGrokHome()
|
||||
grokSignedIn(t, extra, "two@x.ai")
|
||||
if _, err := addGrokLogin(extra); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// a sign-in made through the plugin, in a home of the plugin's
|
||||
pluginHome := filepath.Join(home, ".cache", "opencode-grok-auth", "h1")
|
||||
grokSignedIn(t, pluginHome, "three@x.ai")
|
||||
|
||||
out, before, after := moveAndBack(t, "grok",
|
||||
map[string]any{"type": "oauth", "refresh": GrokHome(), "access": "k", "expires": 0, "accountId": "me@x.ai"},
|
||||
map[string]any{"type": "oauth", "refresh": pluginHome, "access": "k-three@x.ai", "expires": 0, "accountId": "three@x.ai"})
|
||||
if len(out) != 2 || out[0].User != "me@x.ai" || !out[0].Own || out[0].Auth["refresh"] != GrokHome() ||
|
||||
out[1].User != "two@x.ai" || out[1].Own || out[1].Auth["refresh"] != extra || out[1].Auth["access"] != "k-two@x.ai" ||
|
||||
out[1].Auth["expires"].(int64) < time.Now().UnixMilli() {
|
||||
t.Fatalf("out %+v", out)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(extra, "auth.json")); err != nil {
|
||||
t.Fatalf("the moved account's home is gone: %v", err)
|
||||
}
|
||||
ls := loginsOfAgent(after, "grok")
|
||||
if len(ls) != len(loginsOfAgent(before, "grok"))+1 {
|
||||
t.Fatalf("back %+v\nbefore %+v", ls, before)
|
||||
}
|
||||
got := map[string]string{}
|
||||
for _, l := range ls {
|
||||
got[l.User] = l.Home
|
||||
}
|
||||
if got["two@x.ai"] != extra || got["three@x.ai"] != pluginHome || got["me@x.ai"] != "" {
|
||||
t.Fatalf("homes %v", got)
|
||||
}
|
||||
if gl := grokLogins(); len(gl) != 3 {
|
||||
t.Fatalf("grok logins %+v", gl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveCommandCode(t *testing.T) {
|
||||
home := claudeHome(t)
|
||||
writeFile(t, filepath.Join(home, ".commandcode", "auth.json"), cmdAuth{APIKey: "cc-own", UserID: "u1", UserName: "me"})
|
||||
writeLogins([]savedLogin{{Agent: CommandCodePlanID, User: "two", Plan: "Pro", On: true, Auth: []byte(jsonText(cmdAuth{APIKey: "cc-two", UserID: "u2", UserName: "two", KeyName: "laptop"}))}})
|
||||
|
||||
out, before, after := moveAndBack(t, CommandCodePlanID,
|
||||
map[string]any{"type": "api", "key": "cc-own", "metadata": map[string]any{"email": "me"}},
|
||||
map[string]any{"type": "api", "key": "cc-three", "metadata": map[string]any{"email": "three", "userId": "u3"}})
|
||||
if len(out) != 2 || out[0].User != "me" || !out[0].Own || !out[0].First || out[0].Auth["key"] != "cc-own" ||
|
||||
out[1].User != "two" || out[1].Own || out[1].Auth["key"] != "cc-two" || out[1].Auth["metadata"].(map[string]any)["keyName"] != "laptop" {
|
||||
t.Fatalf("out %+v", out)
|
||||
}
|
||||
ls := loginsOfAgent(after, CommandCodePlanID)
|
||||
if len(ls) != len(loginsOfAgent(before, CommandCodePlanID))+1 {
|
||||
t.Fatalf("back %+v\nbefore %+v", ls, before)
|
||||
}
|
||||
for _, l := range ls {
|
||||
a, _ := cmdSaved(l)
|
||||
switch l.User {
|
||||
case "me":
|
||||
if !l.own() {
|
||||
t.Errorf("own %+v", l)
|
||||
}
|
||||
case "two":
|
||||
if a != (cmdAuth{APIKey: "cc-two", UserID: "u2", UserName: "two", KeyName: "laptop"}) || l.Plan != "Pro" {
|
||||
t.Errorf("two %+v %+v", l, a)
|
||||
}
|
||||
case "three":
|
||||
if a.APIKey != "cc-three" || a.UserID != "u3" || a.UserName != "three" {
|
||||
t.Errorf("three %+v", a)
|
||||
}
|
||||
default:
|
||||
t.Errorf("unexpected %+v", l)
|
||||
}
|
||||
}
|
||||
if l := cmdLogins(); len(l) != 3 {
|
||||
t.Fatalf("cmd logins %+v", l)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveCursor(t *testing.T) {
|
||||
claudeHome(t)
|
||||
wasExe, wasOut, was := CursorExecutable, cursorSignedOut, cursorStatus
|
||||
t.Cleanup(func() { CursorExecutable, cursorSignedOut, cursorStatus = wasExe, wasOut, was })
|
||||
CursorExecutable = func() string { return "/bin/sh" }
|
||||
cursorSignedOut = func() bool { return false }
|
||||
cursorStatus = &cliIdentity{name: "cursor-test", exe: func() string { return "/bin/sh" }, ask: func() (string, string, bool, error) { return "me@example.com", "Pro", true, nil }}
|
||||
|
||||
out, _, after := moveAndBack(t, "cursor",
|
||||
map[string]any{"type": "oauth", "access": "", "refresh": "cursor-agent", "expires": 0, "accountId": "me@example.com"})
|
||||
if len(out) != 1 || !out[0].Own || !out[0].First || out[0].User != "me@example.com" ||
|
||||
out[0].Auth["refresh"] != "cursor-agent" || out[0].Auth["access"] != "" || out[0].Auth["plan"] != "Pro" {
|
||||
t.Fatalf("out %+v", out)
|
||||
}
|
||||
if len(loginsOfAgent(after, "cursor")) != 0 {
|
||||
t.Fatalf("cursor saved %+v", after)
|
||||
}
|
||||
// a token the plugin signed in itself has nowhere to go
|
||||
if _, _, err := movers["cursor"].back(nil, "", map[string]any{"type": "oauth", "access": "a", "refresh": "r", "accountId": "x@example.com"}); err == nil {
|
||||
t.Fatal("a browser sign-in went back")
|
||||
}
|
||||
cursorSignedOut = func() bool { return true }
|
||||
if out, _ := movers["cursor"].out(); len(out) != 0 {
|
||||
t.Fatalf("signed out: %+v", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/yetone/magpie/internal/plugin"
|
||||
)
|
||||
|
||||
// fakeMover moves a made-up built-in, "fakeco", whose accounts keep a
|
||||
// token in logins.json, onto the test plugin: the token is the plugin's
|
||||
// refresh token.
|
||||
func fakeMover(t *testing.T, inUse *[]string) {
|
||||
t.Helper()
|
||||
movers["fakeco"] = &mover{
|
||||
pkg: "fake",
|
||||
agents: []string{"fakeco"},
|
||||
out: func() ([]Moving, error) {
|
||||
var out []Moving
|
||||
for _, l := range sideLogins("fakeco", "", func(l savedLogin) bool { return fakeToken(l) != "" }) {
|
||||
out = append(out, Moving{User: l.User, First: l.Active, On: l.On, Lapsed: l.saved.Lapsed != "", Auth: map[string]any{
|
||||
"type": "oauth", "refresh": fakeToken(l.saved), "access": "a", "expires": 9e15, "accountId": l.User,
|
||||
}})
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
back: func(ls []savedLogin, user string, auth map[string]any) ([]savedLogin, string, error) {
|
||||
if user == "" {
|
||||
user = str(auth["accountId"])
|
||||
}
|
||||
i := backInto(&ls, "fakeco", user)
|
||||
ls[i].Auth = json.RawMessage(jsonText(map[string]string{"token": str(auth["refresh"])}))
|
||||
return ls, ls[i].User, nil
|
||||
},
|
||||
}
|
||||
oldInstall, oldInUse := installPlugin, modelsInUse
|
||||
installPlugin = func(context.Context, string, string) error { return nil }
|
||||
modelsInUse = func(string) []string { return *inUse }
|
||||
t.Cleanup(func() {
|
||||
delete(movers, "fakeco")
|
||||
installPlugin, modelsInUse = oldInstall, oldInUse
|
||||
})
|
||||
}
|
||||
|
||||
func fakeToken(l savedLogin) string {
|
||||
var a struct{ Token string }
|
||||
_ = json.Unmarshal(l.Auth, &a)
|
||||
return a.Token
|
||||
}
|
||||
|
||||
func fakeLogin(user, token string, first, on bool) savedLogin {
|
||||
return savedLogin{Agent: "fakeco", User: user, First: first, On: on, Auth: json.RawMessage(jsonText(map[string]string{"token": token}))}
|
||||
}
|
||||
|
||||
func fakeSaved(t *testing.T) map[string]savedLogin {
|
||||
t.Helper()
|
||||
out := map[string]savedLogin{}
|
||||
for _, l := range readLogins() {
|
||||
if l.Agent == "fakeco" {
|
||||
out[l.User] = l
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// A built-in moves onto its plugin only when every account works through
|
||||
// it and it serves every model in use; anything short of that puts the
|
||||
// built-in back as it was, with the tokens the plugin renewed meanwhile.
|
||||
// Moved, the plugin has the accounts in the user's order; moved back, the
|
||||
// built-in has them again, fresh tokens and order included.
|
||||
func TestMoveToPlugin(t *testing.T) {
|
||||
bun, err := exec.LookPath("bun")
|
||||
if err != nil {
|
||||
t.Skip("no bun on PATH")
|
||||
}
|
||||
claudeHome(t)
|
||||
t.Setenv("MAGPIE_BUN", bun)
|
||||
t.Cleanup(plugin.Settle)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
abs, _ := filepath.Abs("../plugin/testdata/fake/index.js")
|
||||
if _, err := plugin.Add(ctx, abs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := plugin.Providers(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inUse := []string{"fake-1", "fake-claude"}
|
||||
fakeMover(t, &inUse)
|
||||
movers["fakeco"].pkg = abs
|
||||
reset := func(ls ...savedLogin) {
|
||||
t.Helper()
|
||||
_ = plugin.SignOut(ctx, "fakeco", "")
|
||||
os.Remove(migrationsPath())
|
||||
loginsMu.Lock()
|
||||
if err := writeLogins(ls); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loginsMu.Unlock()
|
||||
}
|
||||
pluginUsers := func() []string {
|
||||
pp, _ := pluginOfAgent("plugin:fakeco")
|
||||
pp.Accounts = nil
|
||||
for _, p := range plugin.Cached() {
|
||||
if p.ID == "fakeco" {
|
||||
pp = p
|
||||
}
|
||||
}
|
||||
var out []string
|
||||
for _, l := range pluginLogins(pp) {
|
||||
s := l.User
|
||||
if l.On {
|
||||
s += "+"
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// an account the vendor refuses: nothing moves, and the other's token,
|
||||
// spent by the plugin, comes back renewed
|
||||
reset(fakeLogin("a@fake", "rot-a", false, true), fakeLogin("c@fake", "r-dead", false, true))
|
||||
err = Move(ctx, "fakeco")
|
||||
if err == nil || !strings.Contains(err.Error(), "c@fake") {
|
||||
t.Fatalf("Move with a refused account = %v", err)
|
||||
}
|
||||
if m, _ := MigrationOf("fakeco"); m.State != MoveFailed || Moved("fakeco") {
|
||||
t.Fatalf("after a failed move: %+v", m)
|
||||
}
|
||||
if s := fakeSaved(t); !strings.HasPrefix(fakeToken(s["a@fake"]), "rot-a+") || fakeToken(s["c@fake"]) != "r-dead" {
|
||||
t.Fatalf("the built-in's accounts after a failed move: %+v", s)
|
||||
}
|
||||
if plugin.SignedIn("fakeco") {
|
||||
t.Fatal("the plugin kept an account of a failed move")
|
||||
}
|
||||
|
||||
// a model in use the plugin doesn't serve: nothing moves
|
||||
reset(fakeLogin("a@fake", "r-a", false, true))
|
||||
inUse = []string{"fake-1", "gone-model"}
|
||||
if err := Move(ctx, "fakeco"); err == nil || !strings.Contains(err.Error(), "gone-model") {
|
||||
t.Fatalf("Move with a model the plugin lacks = %v", err)
|
||||
}
|
||||
if len(fakeSaved(t)) != 1 || plugin.SignedIn("fakeco") {
|
||||
t.Fatal("a move short of a model moved")
|
||||
}
|
||||
inUse = []string{"fake-1", "fake-claude"}
|
||||
|
||||
// an account signed in through the plugin already gets back what it had
|
||||
reset(fakeLogin("a@fake", "r-a", false, true))
|
||||
if _, err := plugin.Import(ctx, "fakeco", map[string]any{"type": "oauth", "refresh": "r-mine", "access": "a", "expires": 9e15, "accountId": "a@fake"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inUse = []string{"gone-model"}
|
||||
if err := Move(ctx, "fakeco"); err == nil {
|
||||
t.Fatal("moved short of a model")
|
||||
}
|
||||
if a := plugin.Auths("fakeco"); len(a) != 1 || a["fakeco"]["refresh"] != "r-mine" {
|
||||
t.Fatalf("the plugin's own account after a failed move: %v", a)
|
||||
}
|
||||
inUse = []string{"fake-1", "fake-claude"}
|
||||
|
||||
// a move through: b first, a on behind it, d off; a lapsed one goes
|
||||
// along untried
|
||||
reset(fakeLogin("a@fake", "rot-a", false, true), fakeLogin("b@fake", "r-b", true, true), fakeLogin("d@fake", "r-d", false, false),
|
||||
func() savedLogin { l := fakeLogin("e@fake", "r-dead", false, false); l.Lapsed = "expired"; return l }())
|
||||
if err := Move(ctx, "fakeco"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !Moved("fakeco") || PluginID("fakeco") != "fakeco" {
|
||||
t.Fatal("not moved")
|
||||
}
|
||||
if s := fakeSaved(t); len(s) != 0 {
|
||||
t.Fatalf("the built-in kept its accounts in logins.json: %v", s)
|
||||
}
|
||||
if m, _ := MigrationOf("fakeco"); len(m.Backup) != 4 || len(m.Accounts) != 4 {
|
||||
t.Fatalf("the move's record: %+v", m)
|
||||
}
|
||||
if got := strings.Join(pluginUsers(), " "); got != "b@fake+ a@fake+ d@fake e@fake" {
|
||||
t.Fatalf("the plugin's accounts: %s", got)
|
||||
}
|
||||
if err := Move(ctx, "fakeco"); err != nil {
|
||||
t.Fatalf("moving again: %v", err)
|
||||
}
|
||||
|
||||
// the user reorders on the plugin, and it renews a's token; back, the
|
||||
// built-in has both
|
||||
if err := switchPluginLogin(mustPlugin(t), "a@fake"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for k, a := range plugin.Auths("fakeco") {
|
||||
if a["accountId"] == "a@fake" {
|
||||
if _, err := plugin.Check(ctx, "fakeco", k); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := MoveBack(ctx, "fakeco"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m, _ := MigrationOf("fakeco"); m.State != MovedBack || Moved("fakeco") {
|
||||
t.Fatalf("after moving back: %+v", m)
|
||||
}
|
||||
s := fakeSaved(t)
|
||||
if len(s) != 4 || !strings.HasPrefix(fakeToken(s["a@fake"]), "rot-a++") || plugin.SignedIn("fakeco") || !s["a@fake"].First || s["b@fake"].First || !s["b@fake"].On || s["d@fake"].On {
|
||||
t.Fatalf("the built-in's accounts moved back: %+v", s)
|
||||
}
|
||||
if plugin.SignedIn("fakeco") {
|
||||
t.Fatal("the plugin kept accounts moved back")
|
||||
}
|
||||
// the user moved it back: it isn't moved again by itself
|
||||
Retiring = []string{"fakeco"}
|
||||
t.Cleanup(func() { Retiring = nil })
|
||||
if errs := MoveRetiring(ctx); len(errs) != 0 || Moved("fakeco") {
|
||||
t.Fatalf("MoveRetiring moved one moved back: %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
// A move cut short (magpie quit mid-way) is put back before it is tried
|
||||
// again, and a failed one waits before magpie tries it by itself again.
|
||||
func TestMoveCutShort(t *testing.T) {
|
||||
bun, err := exec.LookPath("bun")
|
||||
if err != nil {
|
||||
t.Skip("no bun on PATH")
|
||||
}
|
||||
claudeHome(t)
|
||||
t.Setenv("MAGPIE_BUN", bun)
|
||||
t.Cleanup(plugin.Settle)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
abs, _ := filepath.Abs("../plugin/testdata/fake/index.js")
|
||||
if _, err := plugin.Add(ctx, abs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := plugin.Providers(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inUse := []string{"fake-1"}
|
||||
fakeMover(t, &inUse)
|
||||
movers["fakeco"].pkg = abs
|
||||
Retiring = []string{"fakeco"}
|
||||
t.Cleanup(func() { Retiring = nil })
|
||||
loginsMu.Lock()
|
||||
_ = writeLogins([]savedLogin{fakeLogin("a@fake", "rot-a", false, true)})
|
||||
loginsMu.Unlock()
|
||||
|
||||
// cut short after a's sign-in went over and the plugin renewed it
|
||||
key, err := plugin.Import(ctx, "fakeco", map[string]any{"type": "oauth", "refresh": "rot-a+", "access": "a", "expires": 9e15, "accountId": "a@fake"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = setMigration("fakeco", func(m *Migration) {
|
||||
*m = Migration{State: MoveMoving, At: time.Now(), Accounts: []movedAccount{{Key: key, User: "a@fake"}}}
|
||||
})
|
||||
if movingNow("fakeco") && len(pluginAccounts()) != 0 {
|
||||
t.Fatal("a plugin provider mid-move is listed")
|
||||
}
|
||||
// the built-in fails now (its models aren't the plugin's): put back,
|
||||
// with the renewed token
|
||||
inUse = []string{"gone-model"}
|
||||
if errs := MoveRetiring(ctx); errs["fakeco"] == nil {
|
||||
t.Fatal("moved short of a model")
|
||||
}
|
||||
if s := fakeSaved(t); !strings.HasPrefix(fakeToken(s["a@fake"]), "rot-a+") || plugin.SignedIn("fakeco") {
|
||||
t.Fatalf("after putting back a move cut short: %+v", s)
|
||||
}
|
||||
// failed just now: not tried again by itself until moveRetry has gone
|
||||
inUse = []string{"fake-1"}
|
||||
if errs := MoveRetiring(ctx); len(errs) != 0 || Moved("fakeco") {
|
||||
t.Fatalf("tried again at once: %v", errs)
|
||||
}
|
||||
_ = setMigration("fakeco", func(m *Migration) { m.At = time.Now().Add(-moveRetry - time.Minute) })
|
||||
if errs := MoveRetiring(ctx); errs["fakeco"] != nil || !Moved("fakeco") {
|
||||
t.Fatalf("after moveRetry: %v", errs)
|
||||
}
|
||||
// a magpie that stopped between the record and logins.json: tidied
|
||||
loginsMu.Lock()
|
||||
_ = writeLogins(append(readLogins(), fakeLogin("late@fake", "r-l", false, true)))
|
||||
loginsMu.Unlock()
|
||||
MoveRetiring(ctx)
|
||||
if m, _ := MigrationOf("fakeco"); len(fakeSaved(t)) != 0 || len(m.Backup) != 2 {
|
||||
t.Fatalf("not tidied: %v, %+v", fakeSaved(t), m.Backup)
|
||||
}
|
||||
}
|
||||
|
||||
func mustPlugin(t *testing.T) plugin.Provider {
|
||||
t.Helper()
|
||||
for _, p := range plugin.Cached() {
|
||||
if p.ID == "fakeco" {
|
||||
return p
|
||||
}
|
||||
}
|
||||
t.Fatal("no fakeco plugin provider")
|
||||
return plugin.Provider{}
|
||||
}
|
||||
|
||||
// Putting back a move keeps the newer of the two sign-ins: the plugin's
|
||||
// when it renewed one, the built-in's when that renewed it meanwhile (a
|
||||
// request went to it), none when neither did.
|
||||
func TestPutBackKeepsNewer(t *testing.T) {
|
||||
bun, err := exec.LookPath("bun")
|
||||
if err != nil {
|
||||
t.Skip("no bun on PATH")
|
||||
}
|
||||
claudeHome(t)
|
||||
t.Setenv("MAGPIE_BUN", bun)
|
||||
t.Cleanup(plugin.Settle)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
abs, _ := filepath.Abs("../plugin/testdata/fake/index.js")
|
||||
if _, err := plugin.Add(ctx, abs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := plugin.Providers(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inUse := []string{"fake-1"}
|
||||
fakeMover(t, &inUse)
|
||||
mv := movers["fakeco"]
|
||||
sent := func(tok, user string) string {
|
||||
return signinHash(map[string]any{"type": "oauth", "refresh": tok, "access": "a", "expires": 9e15, "accountId": user})
|
||||
}
|
||||
for _, c := range []struct {
|
||||
name, builtin, plugin, want string
|
||||
}{
|
||||
{"the plugin renewed it", "rot-a", "rot-a+", "rot-a+"},
|
||||
{"the built-in renewed it", "rot-b", "rot-a+", "rot-b"},
|
||||
{"neither did", "rot-a", "rot-a", "rot-a"},
|
||||
} {
|
||||
loginsMu.Lock()
|
||||
_ = writeLogins([]savedLogin{fakeLogin("a@fake", c.builtin, false, true)})
|
||||
loginsMu.Unlock()
|
||||
key, err := plugin.Import(ctx, "fakeco", map[string]any{"type": "oauth", "refresh": c.plugin, "access": "a", "expires": 9e15, "accountId": "a@fake"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = setMigration("fakeco", func(m *Migration) {
|
||||
*m = Migration{State: MoveMoving, At: time.Now(), Accounts: []movedAccount{{Key: key, User: "a@fake", Sent: sent("rot-a", "a@fake")}}}
|
||||
})
|
||||
putBack(ctx, "fakeco", mv, "")
|
||||
if s := fakeSaved(t); fakeToken(s["a@fake"]) != c.want || plugin.SignedIn("fakeco") {
|
||||
t.Fatalf("%s: %+v", c.name, s)
|
||||
}
|
||||
if m, _ := MigrationOf("fakeco"); m.State != MoveFailed {
|
||||
t.Fatalf("%s: %+v", c.name, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WorkBuddy's and WorkBuddy AI's accounts on
|
||||
// @magpie-community/opencode-workbuddy-auth (one package, a provider for
|
||||
// each build). The desktop app's own sign-in goes as a marker ({source:
|
||||
// "desktop"}, the package from 0.1.1 on): the plugin reads the app's file
|
||||
// on each request, as the built-in does, so no refresh token is held by
|
||||
// both. An account magpie signed in goes whole.
|
||||
func init() {
|
||||
for _, w := range []*wbSite{wbCN, wbAI} {
|
||||
movers[w.id] = &mover{
|
||||
pkg: "@magpie-community/opencode-workbuddy-auth",
|
||||
min: "0.1.1", // the desktop app's account read where the app keeps it
|
||||
agents: []string{w.id},
|
||||
out: func() ([]Moving, error) {
|
||||
var out []Moving
|
||||
for _, a := range wbLogins(w) {
|
||||
m := Moving{User: a.User, First: a.Active, On: a.On, Lapsed: a.Lapsed != "", Own: a.own}
|
||||
if a.own {
|
||||
m.Auth = map[string]any{"type": "oauth", "source": "desktop", "access": "", "refresh": "", "expires": 0,
|
||||
"accountId": a.User, "uid": a.creds.UID}
|
||||
} else {
|
||||
c := a.creds
|
||||
m.Auth = map[string]any{"type": "oauth", "access": c.Access, "refresh": c.Refresh, "expires": c.ExpiresAt,
|
||||
"accountId": a.User, "uid": c.UID, "domain": c.Domain}
|
||||
if c.RefreshExpiresAt > 0 {
|
||||
m.Auth["refreshExpiresAt"] = c.RefreshExpiresAt
|
||||
}
|
||||
if c.TokenType != "" {
|
||||
m.Auth["tokenType"] = c.TokenType
|
||||
}
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
back: func(ls []savedLogin, user string, auth map[string]any) ([]savedLogin, string, error) {
|
||||
if str(auth["source"]) != "" {
|
||||
// the desktop app's: nothing of it was the plugin's
|
||||
return ls, firstNonEmpty(user, str(auth["accountId"])), nil
|
||||
}
|
||||
c := wbCreds{UID: str(auth["uid"]), Access: str(auth["access"]), Refresh: str(auth["refresh"]),
|
||||
ExpiresAt: msOf(auth["expires"]), RefreshExpiresAt: msOf(auth["refreshExpiresAt"]),
|
||||
Domain: str(auth["domain"]), TokenType: str(auth["tokenType"])}
|
||||
if c.UID == "" || c.Access == "" {
|
||||
return ls, "", errors.New(w.name + ": an unreadable plugin sign-in")
|
||||
}
|
||||
if user == "" {
|
||||
user = str(auth["accountId"])
|
||||
}
|
||||
i := backInto(&ls, w.id, user)
|
||||
b, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
return ls, "", err
|
||||
}
|
||||
ls[i].Auth, ls[i].Lapsed, ls[i].Seen = b, "", time.Now().UTC().Truncate(time.Second)
|
||||
wbTokens.Lock()
|
||||
delete(wbTokens.m, w.id+"|"+c.UID)
|
||||
wbTokens.Unlock()
|
||||
return ls, ls[i].User, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// msOf is a time in unix ms as JSON gave it.
|
||||
func msOf(v any) int64 {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int64(n)
|
||||
case int64:
|
||||
return n
|
||||
case int:
|
||||
return int64(n)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ZCode's accounts on @magpie-community/opencode-zcode-auth: the plan's
|
||||
// key (else ZCode's session token) as the oauth entry's access, the rest
|
||||
// the requests need as its refresh, as the plugin's own sign-in keeps
|
||||
// them. Nothing here rotates: a key lasts until it is deleted.
|
||||
//
|
||||
// ZCode's own sign-in is kept encrypted, where the plugin can't read it,
|
||||
// so it goes as a copy of what magpie reads now. A key or a team seat
|
||||
// lasts, and the copy with it; ZCode's session token alone (the Start
|
||||
// Plan) is renewed by ZCode, and a copy would lapse behind it, so such an
|
||||
// account stops the move and the built-in carries on.
|
||||
func init() {
|
||||
movers["zcode"] = &mover{
|
||||
pkg: "@magpie-community/opencode-zcode-auth",
|
||||
agents: []string{"zcode"},
|
||||
out: func() ([]Moving, error) {
|
||||
var out []Moving
|
||||
for _, l := range zcodeLogins() {
|
||||
k := l.key
|
||||
if l.Own && k.Key == "" && !k.team() {
|
||||
return nil, errors.New("ZCode's own sign-in has no coding plan key, only ZCode's session, which the plugin can't follow")
|
||||
}
|
||||
// the own account's copy goes back to nothing: ZCode keeps it
|
||||
out = append(out, Moving{User: l.User, First: l.Active, On: l.On, Lapsed: l.Lapsed != "", Own: l.Own, Auth: zcodeOut(l.User, l.Plan, k)})
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
back: func(ls []savedLogin, user string, auth map[string]any) ([]savedLogin, string, error) {
|
||||
var s struct {
|
||||
Site, Device, Key, Base, JWT, Token, Org, Project, Plan string
|
||||
}
|
||||
if json.Unmarshal([]byte(str(auth["refresh"])), &s) != nil {
|
||||
// a key signed in to in the plugin
|
||||
if str(auth["type"]) != "api" || str(auth["key"]) == "" {
|
||||
return ls, "", errors.New("ZCode: an unreadable plugin sign-in")
|
||||
}
|
||||
s.Key = str(auth["key"])
|
||||
if m, _ := auth["metadata"].(map[string]any); m != nil {
|
||||
s.Site = str(m["site"])
|
||||
}
|
||||
} else if s.Key == "" && s.JWT == "" {
|
||||
s.Key = str(auth["access"])
|
||||
}
|
||||
k := zcodeKey{Key: s.Key, Base: s.Base, JWT: s.JWT, Token: s.Token, Org: s.Org, Project: s.Project}
|
||||
if k.Base == "" {
|
||||
k.Base = ZCodeZaiBase
|
||||
if s.Site == "bigmodel" {
|
||||
k.Base = ZCodeBigModelBase
|
||||
}
|
||||
}
|
||||
if k.Key == "" && k.JWT == "" && !k.team() {
|
||||
return ls, "", errors.New("ZCode: an unreadable plugin sign-in")
|
||||
}
|
||||
if user == "" {
|
||||
user = firstNonEmpty(str(auth["accountId"]), "ZCode")
|
||||
}
|
||||
i := backInto(&ls, "zcode", user)
|
||||
b, err := json.Marshal(k)
|
||||
if err != nil {
|
||||
return ls, "", err
|
||||
}
|
||||
ls[i].Auth, ls[i].Lapsed, ls[i].Seen = b, "", time.Now().UTC().Truncate(time.Second)
|
||||
if s.Plan != "" {
|
||||
ls[i].Plan = s.Plan
|
||||
}
|
||||
return ls, ls[i].User, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// zcodeOut is a ZCode account as the plugin's sign-in keeps one.
|
||||
func zcodeOut(user, plan string, k zcodeKey) map[string]any {
|
||||
site := "zai"
|
||||
if strings.Contains(k.Base, "bigmodel.cn") {
|
||||
site = "bigmodel"
|
||||
}
|
||||
state := map[string]any{"site": site, "device": zcodeDeviceMid(), "base": k.Base}
|
||||
for name, v := range map[string]string{"key": k.Key, "jwt": k.JWT, "token": k.Token, "org": k.Org, "project": k.Project, "plan": plan} {
|
||||
if v != "" {
|
||||
state[name] = v
|
||||
}
|
||||
}
|
||||
expires := int64(0)
|
||||
if k.Key == "" && !k.team() {
|
||||
if exp, _ := jwtClaims(k.JWT)["exp"].(float64); exp > 0 {
|
||||
expires = int64(exp) * 1000
|
||||
}
|
||||
}
|
||||
return map[string]any{"type": "oauth", "access": firstNonEmpty(k.Key, k.JWT), "refresh": jsonText(state), "expires": expires, "accountId": user}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Zed's accounts on @magpie-community/opencode-zed-auth: the access token
|
||||
// as the oauth entry's access, the rest the calls need as its refresh
|
||||
// (Zed has no refresh token: the pair lasts until Zed refuses it).
|
||||
func init() {
|
||||
movers["zed"] = &mover{
|
||||
pkg: "@magpie-community/opencode-zed-auth",
|
||||
agents: []string{"zed"},
|
||||
out: func() ([]Moving, error) {
|
||||
var out []Moving
|
||||
for _, l := range zedLogins() {
|
||||
c := l.creds
|
||||
out = append(out, Moving{User: l.User, First: l.Active, On: l.On, Lapsed: l.Lapsed != "", Auth: map[string]any{
|
||||
"type": "oauth",
|
||||
"access": c.Access,
|
||||
"refresh": jsonText(map[string]any{"userId": c.UserID, "systemId": c.SystemID, "org": c.Org, "plan": c.Plan, "login": c.Login, "name": c.Name}),
|
||||
"expires": 0,
|
||||
"accountId": l.User,
|
||||
}})
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
back: func(ls []savedLogin, user string, auth map[string]any) ([]savedLogin, string, error) {
|
||||
var r struct {
|
||||
UserID any `json:"userId"`
|
||||
SystemID string `json:"systemId"`
|
||||
Org string `json:"org"`
|
||||
Plan string `json:"plan"`
|
||||
Login string `json:"login"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
access := str(auth["access"])
|
||||
if json.Unmarshal([]byte(str(auth["refresh"])), &r) != nil || r.UserID == nil || access == "" {
|
||||
return ls, "", errors.New("Zed: an unreadable plugin sign-in")
|
||||
}
|
||||
if user == "" {
|
||||
user = str(auth["accountId"])
|
||||
}
|
||||
i := backInto(&ls, "zed", user)
|
||||
c, _ := zedSaved(ls[i])
|
||||
// the plugin may keep the id as the number Zed gives
|
||||
c.UserID, c.Access, c.SystemID, c.Org, c.Plan = strings.Trim(jsonText(r.UserID), `"`), access, r.SystemID, r.Org, r.Plan
|
||||
if r.Login != "" {
|
||||
c.Login = r.Login
|
||||
}
|
||||
if r.Name != "" {
|
||||
c.Name = r.Name
|
||||
}
|
||||
b, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
return ls, "", err
|
||||
}
|
||||
ls[i].Auth, ls[i].Lapsed, ls[i].Seen = b, "", time.Now().UTC().Truncate(time.Second)
|
||||
return ls, ls[i].User, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// backInto is the index in ls of agent's saved account user, added (on)
|
||||
// when there is none.
|
||||
func backInto(ls *[]savedLogin, agent, user string) int {
|
||||
for i, l := range *ls {
|
||||
if l.Agent == agent && strings.EqualFold(l.User, user) && !l.own() {
|
||||
return i
|
||||
}
|
||||
}
|
||||
*ls = append(*ls, savedLogin{Agent: agent, User: user, On: true, Seen: time.Now().UTC().Truncate(time.Second)})
|
||||
return len(*ls) - 1
|
||||
}
|
||||
|
||||
func jsonText(v any) string {
|
||||
b, _ := json.Marshal(v)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func str(v any) string {
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
@@ -32,8 +32,12 @@ const ConversationHeader = "X-Magpie-Conversation"
|
||||
|
||||
// PluginID is the id magpie gives the provider OpenCode calls id: the same,
|
||||
// unless a preset or a built-in subscription has it (google, openai,
|
||||
// anthropic), when it is id-plugin.
|
||||
// anthropic), when it is id-plugin — but for a built-in moved onto the
|
||||
// plugin, whose id the plugin has now.
|
||||
func PluginID(id string) string {
|
||||
if Moved(id) {
|
||||
return id
|
||||
}
|
||||
if slices.Contains(accountIDs, id) || Preset(id) != nil || id == "magpie" {
|
||||
return id + "-plugin"
|
||||
}
|
||||
@@ -58,6 +62,9 @@ func PluginOf(id string) (plugin.Provider, bool) {
|
||||
func pluginAccounts() []Provider {
|
||||
var out []Provider
|
||||
for _, pp := range plugin.Cached() {
|
||||
if movingNow(pp.ID) {
|
||||
continue // shown once the move is through (migrate.go)
|
||||
}
|
||||
if ls := pluginLogins(pp); len(ls) > 0 {
|
||||
out = append(out, pluginProvider(pp, ls[0].acct, ls[0].User))
|
||||
}
|
||||
|
||||
+30
-1
@@ -21,7 +21,9 @@ const pluginUsage = `usage: magpie plugin [list] [--json]
|
||||
magpie plugin update install the newest version of each
|
||||
magpie plugin on|off <name> turn one on or off
|
||||
magpie plugin login <provider> [<method>] sign in to a provider a plugin adds
|
||||
magpie plugin logout <provider> forget the sign-in`
|
||||
magpie plugin logout <provider> forget the sign-in
|
||||
magpie plugin move <subscription> run a built-in subscription's accounts on its community plugin
|
||||
magpie plugin move-back <subscription> go back to the built-in`
|
||||
|
||||
// pluginCmd: `magpie plugin …` — OpenCode's provider plugins, which sign in
|
||||
// to a subscription and carry its requests (internal/plugin).
|
||||
@@ -95,6 +97,33 @@ func pluginCmd(args []string) error {
|
||||
}
|
||||
fmt.Println(green.Render("✓"), "signed out of", pp.Name)
|
||||
return nil
|
||||
case "move", "migrate":
|
||||
if len(rest) != 1 {
|
||||
return errors.New(pluginUsage)
|
||||
}
|
||||
if !provider.Movable(rest[0]) {
|
||||
return fmt.Errorf("%s has no plugin to move to", rest[0])
|
||||
}
|
||||
if !plugin.HasBun() {
|
||||
fmt.Println(muted.Render("Downloading Bun " + plugin.BunVersion + ", which plugins run on…"))
|
||||
}
|
||||
if err := provider.Move(ctx, rest[0]); err != nil {
|
||||
return fmt.Errorf("%s stays built-in: %w", rest[0], err)
|
||||
}
|
||||
fmt.Println(green.Render("✓"), rest[0], "runs on", provider.MovePackage(rest[0]), muted.Render("(magpie plugin move-back "+rest[0]+" to undo)"))
|
||||
return nil
|
||||
case "move-back", "moveback", "unmigrate":
|
||||
if len(rest) != 1 {
|
||||
return errors.New(pluginUsage)
|
||||
}
|
||||
if !provider.Moved(rest[0]) {
|
||||
return fmt.Errorf("%s isn't on its plugin", rest[0])
|
||||
}
|
||||
if err := provider.MoveBack(ctx, rest[0]); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(green.Render("✓"), rest[0], "is built-in again")
|
||||
return nil
|
||||
case "help", "-h", "--help":
|
||||
fmt.Println(pluginUsage)
|
||||
return nil
|
||||
|
||||
Reference in new issue
Block a user