diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 315d7276..c5326484 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -172,14 +172,41 @@ func Running() bool { // does, and whether the magpie serving it has a window its routing can be // watched in (#110). func Serving() (running, window bool) { + s := ServedBy() + return s.Running, s.Window +} + +// Served is what the magpie answering at the gateway's address says of +// itself. +type Served struct { + Running bool // a magpie gateway answers + Window bool // its routing can be watched in its window (#110) + Version string // its version; "" from one that doesn't say +} + +// ServedBy asks the address which magpie answers there. Another magpie can +// keep the port after this one is updated (a magpie serve left running, a +// second copy, one that didn't quit), and every agent's request is then +// still that one's to send: in #506 a Factory 403 came worded as v0.1.550 +// and earlier word it to two people whose magpie said v0.1.630, so none of +// the fixes since reached their requests. Its version tells it apart. +func ServedBy() Served { c := &http.Client{Timeout: 700 * time.Millisecond} res, err := c.Get(URL() + "/") if err != nil { - return false, false + return Served{} } defer res.Body.Close() - b, _ := io.ReadAll(io.LimitReader(res.Body, 4096)) - return bytes.Contains(b, []byte(`"magpie"`)), bytes.Contains(b, []byte(`"window":true`)) + b, _ := io.ReadAll(io.LimitReader(res.Body, 64<<10)) + var info struct { + Name string `json:"name"` + Version string `json:"version"` + Window bool `json:"window"` + } + if json.Unmarshal(b, &info) != nil || info.Name != "magpie" { + return Served{Running: bytes.Contains(b, []byte(`"magpie"`)), Window: bytes.Contains(b, []byte(`"window":true`))} + } + return Served{Running: true, Window: info.Window, Version: info.Version} } // Call is one request the gateway handled, for the status views. diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index 862537c3..137ae449 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -1423,6 +1423,7 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; } .gw .who { flex: 1 1 auto; } .gw .name .state { margin-left: 8px; font-size: 11px; font-weight: 500; color: var(--green); } .gw .dot:not(.on) ~ .who .state { color: var(--amber); } +.gw .sub.old { white-space: normal; overflow: visible; text-overflow: clip; } .gw .url { display: inline-flex; align-items: center; height: 24px; padding: 0 8px; border-radius: 6px; background: var(--pill); box-shadow: inset 0 0 0 1px var(--line-2); flex: none; transition: background .12s, box-shadow .12s; } .gw .url:hover { background: var(--pill-hover); box-shadow: inset 0 0 0 1px var(--line); } .gw .url code { color: var(--fg-2); font-size: 11px; } diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 303c910e..989dd7f7 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -3187,14 +3187,21 @@ function renderGateway() { const g = providers.gateway; const box = $("#gateway"); box.replaceChildren(); - const dot = el("span", "dot " + (g.running ? "on" : "")); + // an older magpie keeping the port sends every agent's request its own + // way, without this version's fixes (#506): said in place of the counts + const older = g.running && !g.mine && g.older; + const dot = el("span", "dot " + (older ? "old" : g.running ? "on" : "")); const who = el("div", "who"); const name = el("div", "name", t("Gateway")); - name.append(el("span", "state", t(g.running ? (g.mine ? "running" : "running · served by another magpie") : "not running"))); + name.append(el("span", "state", older + ? t("running · served by magpie {v}, older than this one", { v: g.version }) + : t(g.running ? (g.mine ? "running" : "running · served by another magpie") : "not running"))); const routed = new Set(); for (const p of providers.providers) for (const a of p.agents) if (a.current) routed.add(a.id); const n = routed.size; - who.append(name, el("div", "sub", g.running + if (older) { + who.append(name, el("div", "sub old", t("Agents' requests go through magpie {v} and are sent as it sends them, without this version's fixes. Quit that magpie (a magpie serve, another copy) and this one takes the gateway over within 15 seconds.", { v: g.version }))); + } else who.append(name, el("div", "sub", g.running ? [t(g.models === 1 ? "{n} model" : "{n} models", { n: g.models }), n ? t(n === 1 ? "{n} agent routed through it" : "{n} agents routed through it", { n }) : t("no agent routed through it yet"), t("four APIs, one URL")].join(" · ") : t("start it with magpie serve, or open magpie at login"))); const url = el("button", "url"); @@ -6187,7 +6194,8 @@ const SUBS = [ { agent: "zed", name: "Zed", icon: "zed", plans: "Pro · Student · Business", own: true, risk: true, riskNote: "Zed serves these models to its own editor; magpie signs requests as the editor would, which Zed may treat as third-party use and act on. Use an account you can afford to lose." }, // Factory's plans (Droid's account), signed in with WorkOS's device code as droid does; droid's own login stays its own - { agent: "factory", name: "Factory", icon: "factory", plans: "Pro · Plus · Max", own: true, risk: true, + // or added by an API key (fk-…), as droid takes FACTORY_API_KEY (the import box) + { agent: "factory", name: "Factory", icon: "factory", plans: "Pro · Plus · Max", own: true, risk: true, importable: true, riskNote: "Factory serves these models to its own Droid CLI; magpie signs requests as Droid would, which Factory may treat as third-party use and act on. Use an account you can afford to lose." }, // Xiaomi MiMo's models (its free offer, MiMo plans), signed in at account.xiaomi.com as MiMo's app is { agent: "mimo-app", name: "Xiaomi MiMo", icon: "mimocode", plans: "Free · Starter · Plus · Pro · Ultra", own: true, risk: true, @@ -6208,7 +6216,8 @@ const subOf = (agent) => { const pl = pluginSubs().find((x) => x.agent === agent); if (own && pl && movedSub(agent)) { const { name, icon, plans, risk, riskNote, hint, sites, importable } = own; - return { ...pl, name, icon, plans, risk, riskNote, hint, sites, importable, moved: true }; + // a Factory API key is kept by the built-in, which the plugin replaces + return { ...pl, name, icon, plans, risk, riskNote, hint, sites, importable: importable && agent !== "factory", moved: true }; } return own || pl; }; @@ -6239,6 +6248,17 @@ function pluginSubs() { // come from, and who they are checked with. A ChatGPT or Claude sign-in is // refreshed as it comes in, which spends the file's refresh token. function importSay(agent) { + if (agent === "factory") { + return { + from: t("Add accounts by their Factory API keys (fk-…), as Droid takes FACTORY_API_KEY"), + title: t("Add Factory accounts by API key"), + intro: t("Paste one or more Factory API keys (fk-…, from app.factory.ai/settings/api-keys), one a line, or choose a file with them. Each key is checked with Factory before it is added."), + checking: t("Checking the keys with Factory…"), + checks: t("Each key is asked whose account it is, as Droid does with FACTORY_API_KEY."), + instead: t("Use an API key instead…"), + row: t("Add an account by API key…"), + }; + } if (agent === "codex" || agent === "claude") { const vendor = agent === "codex" ? "ChatGPT" : "Claude"; const own = agent === "codex" ? "Codex's auth.json" : "Claude Code's .credentials.json"; @@ -6547,7 +6567,7 @@ function renderSigning(sub) { const close = el("button", "text", t("Cancel")); close.onclick = cancelSignIn; if (sub.importable) { - const imp = el("button", "text", t("Import instead…")); + const imp = el("button", "text", importSay(sub.agent).instead || t("Import instead…")); imp.title = importSay(sub.agent).from; imp.onclick = () => startImport(sub.agent); box.append(close, imp, go); @@ -6689,7 +6709,7 @@ function renderSigning(sub) { } if (sub.importable) { // an account another tool is signed in to comes in from its file - const imp = el("button", "link", t("Import from a file instead…")); + const imp = el("button", "link", importSay(sub.agent).instead || t("Import from a file instead…")); imp.title = importSay(sub.agent).from; imp.onclick = () => startImport(sub.agent); const acts = tt.querySelector(".acts") || tt.appendChild(el("span", "acts")); @@ -6861,7 +6881,7 @@ function renderAccounts(a, p) { const imp = el("button", "acc add"); const ic2 = el("span", "dot"); ic2.append(svg(PLUS, 10, 1.8)); - imp.append(ic2, el("span", "n", t("Import accounts from a file…"))); + imp.append(ic2, el("span", "n", importSay(a.agent).row || t("Import accounts from a file…"))); imp.title = importSay(a.agent).from; imp.onclick = () => startImport(a.agent); list.append(imp); @@ -6964,7 +6984,7 @@ function renderLoginImport(sub) { } box.append(el("span", "mark", "↑")); const say = importSay(sub.agent); - tt.append(el("span", "n", t("Import {name} accounts", { name: sub.name })), el("span", "s", say.intro)); + tt.append(el("span", "n", say.title || t("Import {name} accounts", { name: sub.name })), el("span", "s", say.intro)); if (say.spent) tt.append(el("span", "s", say.spent)); if (sub.risk) tt.append(el("span", "s", t(sub.riskNote || "Google may suspend an Antigravity account it sees used outside Antigravity. Use one you can afford to lose."))); const area = el("textarea"); diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index f55fb672..606c9cb2 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -596,6 +596,13 @@ const I18N = { "{user} added — switch to it any time": "已添加 {user},随时可以切换", "{user} is already listed — its sign-in was renewed": "{user}:该账号已在列表中,已更新登录", "Import instead…": "改为导入…", + "Add accounts by their Factory API keys (fk-…), as Droid takes FACTORY_API_KEY": "用 Factory API Key(fk-…)添加账号,与 Droid 读取 FACTORY_API_KEY 的方式相同", + "Add Factory accounts by API key": "用 API Key 添加 Factory 账号", + "Paste one or more Factory API keys (fk-…, from app.factory.ai/settings/api-keys), one a line, or choose a file with them. Each key is checked with Factory before it is added.": "粘贴一个或多个 Factory API Key(fk-…,可在 app.factory.ai/settings/api-keys 创建),每行一个,或选择包含它们的文件。每个 Key 都会先向 Factory 校验再添加。", + "Checking the keys with Factory…": "正在向 Factory 校验 Key…", + "Each key is asked whose account it is, as Droid does with FACTORY_API_KEY.": "逐个查询 Key 所属的账号,与 Droid 处理 FACTORY_API_KEY 时相同。", + "Use an API key instead…": "改用 API Key…", + "Add an account by API key…": "用 API Key 添加账号…", "Bring in accounts exported from Antigravity Cockpit, Antigravity Manager or CLIProxyAPI": "导入从 Antigravity Cockpit、Antigravity Manager 或 CLIProxyAPI 导出的账号", "Import accounts from a file…": "从文件导入账号…", "Import {name} accounts": "导入 {name} 账号", @@ -845,6 +852,8 @@ const I18N = { // gateway "running": "运行中", "running · served by another magpie": "运行中 · 由另一个 magpie 提供", + "running · served by magpie {v}, older than this one": "运行中 · 由更旧的 magpie {v} 提供", + "Agents' requests go through magpie {v} and are sent as it sends them, without this version's fixes. Quit that magpie (a magpie serve, another copy) and this one takes the gateway over within 15 seconds.": "Agent 的请求经由 magpie {v} 发出,按它的方式发送,不含当前版本的修复。退出那个 magpie(magpie serve 或另一个副本)后,当前版本会在 15 秒内接管网关。", "not running": "未运行", "{n} agent routed through it": "{n} 个 Agent 经由此路由", "{n} agents routed through it": "{n} 个 Agent 经由此路由", diff --git a/internal/gui/backend.go b/internal/gui/backend.go index 39191d0f..d480d44d 100644 --- a/internal/gui/backend.go +++ b/internal/gui/backend.go @@ -11,6 +11,7 @@ import ( "github.com/yetone/magpie/internal/catalog" "github.com/yetone/magpie/internal/gateway" "github.com/yetone/magpie/internal/provider" + "github.com/yetone/magpie/internal/update" ) // served is the gateway this process serves, nil while another magpie @@ -79,8 +80,13 @@ func watchGateway() { func serveGateway() *gateway.Server { // handing over, the one there is this one's predecessor, which lets go // once this one listens beside it - if !gateway.Handover && gateway.Running() { - return nil + if !gateway.Handover { + if o := gateway.ServedBy(); o.Running { + if update.Newer(gateway.Version, o.Version) { + log.Printf("gateway: magpie %s serves %s, older than this one (%s); agents' requests go through it until it quits", o.Version, gateway.URL(), gateway.Version) + } + return nil + } } gw := gateway.New() served.Store(gw) diff --git a/internal/gui/older_gateway_test.go b/internal/gui/older_gateway_test.go new file mode 100644 index 00000000..2664b633 --- /dev/null +++ b/internal/gui/older_gateway_test.go @@ -0,0 +1,55 @@ +package gui + +import ( + "net" + "net/http" + "path/filepath" + "testing" + + "github.com/yetone/magpie/internal/gateway" +) + +// An older magpie keeping the gateway's port sends every agent's request +// its own way (#506: a Factory 403 worded as v0.1.550 words it, to people +// on v0.1.630). The Gateway page says which version serves and that it is +// older; a newer or the same one is just another magpie. +func TestGatewayServedByAnOlderMagpie(t *testing.T) { + h := t.TempDir() + t.Setenv("HOME", h) + t.Setenv("USERPROFILE", h) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(h, ".config")) + t.Setenv("XDG_CACHE_HOME", filepath.Join(h, ".cache")) + t.Setenv("XDG_DATA_HOME", filepath.Join(h, ".local", "share")) + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Setenv("MAGPIE_ADDR", ln.Addr().String()) + var answer string + other := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(answer)) + })} + go other.Serve(ln) + defer other.Close() + was := gateway.Version + gateway.Version = "0.1.630" + defer func() { gateway.Version = was }() + served.Store(nil) + + for _, c := range []struct { + answer, version string + older, window bool + }{ + {`{"name":"magpie","version":"0.1.550","models":3,"window":true,"apis":[]}`, "0.1.550", true, true}, + {`{"name":"magpie","version":"0.1.630","models":3,"window":false}`, "0.1.630", false, false}, + {`{"name":"magpie","version":"0.1.640","models":3}`, "0.1.640", false, false}, + {`{"name":"magpie","models":3}`, "", false, false}, // one that doesn't say + } { + answer = c.answer + g := providersState().Gateway + if !g.Running || g.Mine || g.Version != c.version || g.Older != c.older || g.Window != c.window { + t.Errorf("%s: running %v mine %v version %q older %v window %v", c.answer, g.Running, g.Mine, g.Version, g.Older, g.Window) + } + } +} diff --git a/internal/gui/providers.go b/internal/gui/providers.go index 03c2acc2..d8713efe 100644 --- a/internal/gui/providers.go +++ b/internal/gui/providers.go @@ -17,6 +17,7 @@ import ( "github.com/yetone/magpie/internal/gateway" "github.com/yetone/magpie/internal/provider" "github.com/yetone/magpie/internal/settings" + "github.com/yetone/magpie/internal/update" ) // The providers page: the vendors the user added, the presets they can add @@ -174,6 +175,11 @@ type gatewayJSON struct { Running bool `json:"running"` Mine bool `json:"mine"` // this process serves it Window bool `json:"window"` // the magpie serving it shows its routing + // Version is another magpie's, serving it, and Older says it is older + // than this one: agents' requests are then sent as that version sends + // them, without this one's fixes (#506) + Version string `json:"version,omitempty"` + Older bool `json:"older,omitempty"` Models int `json:"models"` Calls []gateway.Call `json:"calls"` Groups []gwGroupJSON `json:"groups"` // the catalog's routing groups, listed before the models @@ -472,7 +478,9 @@ func providersState() providersJSON { s.Gateway.Running, s.Gateway.Mine, s.Gateway.Window = true, true, true s.Gateway.Calls = gw.Recent() } else { - s.Gateway.Running, s.Gateway.Window = gateway.Serving() + o := gateway.ServedBy() + s.Gateway.Running, s.Gateway.Window, s.Gateway.Version = o.Running, o.Window, o.Version + s.Gateway.Older = o.Running && update.Newer(gateway.Version, o.Version) } s.Gateway.Archive = archiveState() s.CodexDaemon = provider.CodexDaemonStale() @@ -1104,6 +1112,11 @@ func providerRoutes(mux *http.ServeMux, w Windows) { // ChatGPT and Claude sign-ins: CLIProxyAPI's auth files, Codex // CLI's auth.json, Claude Code's .credentials.json imp = provider.ImportLogins + } else if in.Agent == "factory" { + // Factory API keys (fk-…), as droid takes FACTORY_API_KEY (#506) + imp = func(ctx context.Context, _ string, files []string) ([]provider.ImportedAccount, error) { + return provider.ImportFactoryKeys(ctx, files) + } } res, err := imp(ctx, in.Agent, in.Files) if err != nil { diff --git a/internal/gui/tests/factory-key.test.cjs b/internal/gui/tests/factory-key.test.cjs new file mode 100644 index 00000000..66cb5ca4 --- /dev/null +++ b/internal/gui/tests/factory-key.test.cjs @@ -0,0 +1,111 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// A Factory account comes in by its API key (fk-…), as droid takes +// FACTORY_API_KEY (aohun on #506): the accounts list and the warning before +// a sign-in both offer it, the box says where keys are made and that each is +// checked with Factory, what is pasted is posted as it is, and each key's +// outcome is listed; in English and Chinese, nothing new left untranslated. +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 pasted = "FACTORY_API_KEY=fk-abc_0123456789\nfk-bad_0123456789"; +const account = (logins) => ({ + id: "factory", name: "Factory", icon: "factory", chat: "", responses: "", anthropic: "", catalog: "", + models: [{ id: "glm-5.3-flash", name: "GLM-5.3 Flash", on: true }], agents: [], fallback: [], headers: {}, keyList: [], + account: { agent: "factory", agentName: "Droid", user: "me@example.com", logins }, +}); +const before = [{ user: "me@example.com", active: true, on: true }]; +const after = [...before, { user: "key@example.com", on: true }]; + +function serve(lang, posted) { + let providers = { providers: [account(before)], presets: [], excluded: [], gateway: { running: true, window: true } }; + const state = { agents: [], profiles: [], settings: { lang, theme: "light" } }; + 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(state); + if (url.pathname === "/api/providers") return json(providers); + if (url.pathname === "/api/groups") return json({ groups: [] }); + if (url.pathname === "/api/signin") throw new Error("a sign-in was started"); + if (url.pathname === "/api/signin/import") { + posted.push(route.request().postDataJSON()); + await new Promise((r) => setTimeout(r, 300)); + providers = { ...providers, providers: [account(after)] }; + return json({ providers, results: [ + { user: "key@example.com", status: "added" }, + { user: "fk-bad_…6789", status: "failed", error: "Factory didn't take this key: Factory: Invalid API key" }, + ] }); + } + 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: "" }); } + }; +} + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + for (const lang of ["en", "zh"]) { + test(`${engine} ${lang}: a Factory account comes in by its API key`, async (t) => { + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch()); + const context = await browser.newContext({ viewport: { width: 900, height: 760 }, reducedMotion: "reduce" }); + const page = await context.newPage(); + page.setDefaultTimeout(5000); + const errors = []; + page.on("pageerror", (e) => errors.push(e.message)); + const posted = []; + await page.route("**/*", serve(lang, posted)); + t.after(() => browser.close()); + await page.goto("http://magpie.test/?view=providers"); + await page.locator(".row.provider", { hasText: "Factory" }).click(); + const adds = page.locator(".editor .accts .acc.add"); + await adds.nth(1).waitFor(); + const T = async (s, v) => page.evaluate(([s, v]) => t(s, v), [s, v || {}]); + if (lang === "zh") { + for (const s of ["Add accounts by their Factory API keys (fk-…), as Droid takes FACTORY_API_KEY", "Add Factory accounts by API key", + "Paste one or more Factory API keys (fk-…, from app.factory.ai/settings/api-keys), one a line, or choose a file with them. Each key is checked with Factory before it is added.", + "Checking the keys with Factory…", "Each key is asked whose account it is, as Droid does with FACTORY_API_KEY.", + "Use an API key instead…", "Add an account by API key…"]) { + assert.notEqual(await T(s), s, "no Chinese for: " + s); + } + } + const say = { + row: await T("Add an account by API key…"), + instead: await T("Use an API key instead…"), + title: await T("Add Factory accounts by API key"), + checking: await T("Checking the keys with Factory…"), + }; + if (lang === "en") assert.equal(say.row, "Add an account by API key…"); + + // the warning before a sign-in offers the key instead + await adds.nth(0).click(); + const instead = page.locator(".signing button", { hasText: say.instead }); + await instead.waitFor(); + assert.match(await instead.getAttribute("title"), /FACTORY_API_KEY/); + await instead.click(); + const box = page.locator(".signing.import"); + await box.waitFor(); + assert.equal(await box.locator(".tt .n").first().textContent(), say.title); + assert.match((await box.locator(".tt > .s").allTextContents())[0], /app\.factory\.ai\/settings\/api-keys/); + await box.getByRole("button", { name: await T("Cancel") }).click(); + + // and so does the accounts list + assert.equal((await adds.nth(1).locator(".n").textContent()).trim(), say.row); + await adds.nth(1).click(); + await page.locator(".signing.import textarea").fill(pasted); + await page.locator(".signing.import button.primary").click(); + await page.locator(".signing.import .spinner").waitFor(); + assert.equal(await page.locator(".signing.import .tt .n").textContent(), say.checking); + await page.locator(".signing.import .results").waitFor(); + assert.deepEqual(posted, [{ agent: "factory", files: [pasted] }]); + assert.match(await page.locator(".signing.import .res.failed .why").textContent(), /didn't take this key/); + await page.locator(".editor .accts .acc .n", { hasText: "key@example.com" }).waitFor(); + assert(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), "the page scrolls sideways"); + assert.deepEqual(errors, []); + }); + } +} diff --git a/internal/gui/tests/gateway-older.test.cjs b/internal/gui/tests/gateway-older.test.cjs new file mode 100644 index 00000000..fd3c4f3d --- /dev/null +++ b/internal/gui/tests/gateway-older.test.cjs @@ -0,0 +1,79 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// An older magpie keeping the gateway's port sends every agent's request its +// own way, without this version's fixes (#506: a Factory 403 worded as +// v0.1.550 words it, to people on v0.1.630). The Gateway card says which +// version serves and what to do, in English and Chinese; another magpie of +// the same version is just "served by another magpie". +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"); + +function fixture(lang, gateway) { + const providers = { + providers: [{ id: "fixture", name: "Fixture", icon: "generic", models: [{ id: "model-a", name: "Model A", on: true }], agents: [] }], + gateway: { running: true, window: true, mine: false, url: "http://127.0.0.1:3999", calls: [], groups: [], models: 1, ...gateway }, + }; + const state = { agents: [], profiles: [], settings: { lang, theme: "light" } }; + return async (route) => { + const url = new URL(route.request().url()); + const json = (data) => route.fulfill({ json: data }); + if (route.request().method() !== "GET" && url.pathname.startsWith("/api/")) return json({}); + 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(state); + if (url.pathname === "/api/providers") return json(providers); + if (url.pathname === "/api/groups") return json({ groups: [] }); + if (url.pathname === "/api/gateway/trace") { + if (url.searchParams.get("wait")) await new Promise((r) => setTimeout(r, 20e3)); + return json({ mine: false, now: new Date().toISOString(), seq: 1, totals: { requests: 0, rerouted: 0, errors: 0 }, routes: [] }); + } + 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)]; + await route.fulfill({ body: await fs.readFile(file), contentType }); + }; +} + +const cases = [ + { lang: "en", state: "running · served by magpie 0.1.550, older than this one", sub: /through magpie 0\.1\.550.*Quit that magpie.*within 15 seconds/, same: "running · served by another magpie" }, + { lang: "zh", state: "运行中 · 由更旧的 magpie 0.1.550 提供", sub: /经由 magpie 0\.1\.550.*退出那个 magpie.*15 秒内接管网关/, same: "运行中 · 由另一个 magpie 提供" }, +]; + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + for (const c of cases) { + test(`${engine} ${c.lang}: an older magpie serving the gateway is named`, async (t) => { + assert(["chromium", "webkit"].includes(engine), "BROWSER must be chromium or webkit"); + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch()); + t.after(() => browser.close()); + const errors = []; + for (const [gw, older] of [[{ version: "0.1.550", older: true }, true], [{ version: "0.1.630" }, false]]) { + const context = await browser.newContext({ viewport: { width: 420, height: 640 }, reducedMotion: "reduce" }); + const page = await context.newPage(); + page.setDefaultTimeout(5000); + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/*", fixture(c.lang, gw)); + await page.goto("http://magpie.test/?view=gateway"); + const state = page.locator("#gateway .state"); + await state.waitFor(); + if (older) { + assert.equal((await state.textContent()).trim(), c.state); + assert.match(await page.locator("#gateway .sub").textContent(), c.sub); + assert(await page.locator("#gateway .dot").evaluate((d) => !d.classList.contains("on")), "the dot isn't green"); + // the whole text is there to read, nothing cut off, and the page doesn't scroll sideways + const fits = await page.locator("#gateway .sub").evaluate((s) => s.scrollWidth <= s.clientWidth + 1); + assert(fits, "the advice is cut off"); + assert(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), "the page scrolls sideways"); + } else { + assert.equal((await state.textContent()).trim(), c.same); + assert(await page.locator("#gateway .dot").evaluate((d) => d.classList.contains("on"))); + } + await context.close(); + } + assert.deepEqual(errors, []); + }); + } +} diff --git a/internal/provider/factory.go b/internal/provider/factory.go index aa6d0c21..960931a6 100644 --- a/internal/provider/factory.go +++ b/internal/provider/factory.go @@ -78,6 +78,10 @@ type factoryCreds struct { // Prem is whoami's premBaseHostV2: an org Factory serves from a host // of its own, where droid sends its model requests instead. Prem string `json:"premBaseHost,omitempty"` + // Key: Access is a Factory API key (fk-…), as droid takes from + // FACTORY_API_KEY: sent as it is, never renewed, with no active org + // (droid's comes from a sign-in) — see factory_key.go. + Key bool `json:"apiKey,omitempty"` } // base is the Factory API the account's org is served from. @@ -295,6 +299,9 @@ func factoryFresh(ctx context.Context, user string) (factoryCreds, error) { if !valid { return factoryCreds{}, errors.New("Factory: unreadable sign-in") } + if c.Key { + return c, nil // an API key: nothing to renew, and no org with it + } if c.ExpiresAt > 0 && time.Now().UnixMilli() < c.ExpiresAt-factoryRefreshLead.Milliseconds() { return factoryOrgOf(ctx, l.User, c), nil } @@ -444,8 +451,8 @@ func factoryMendOrg(ctx context.Context, user string, status int, body []byte) b return false } c, valid := factorySaved(l) - if !valid { - return false + if !valid || c.Key { + return false // an API key carries no org droid would send } if c.Active != "" { if !refused { diff --git a/internal/provider/factory_key.go b/internal/provider/factory_key.go new file mode 100644 index 00000000..9a25971c --- /dev/null +++ b/internal/provider/factory_key.go @@ -0,0 +1,100 @@ +package provider + +// A Factory account added by its API key (fk-…), as droid takes one from +// FACTORY_API_KEY (#506): droid's om() hands the key on as the token, sent as +// "Authorization: Bearer fk-…" with the headers every request carries, and +// never renewed. droid's active org (st(), X-Factory-Org-Id) is what a +// sign-in stored, so a key alone sends none; whoami, asked with the key, +// says whose it is and where Factory serves its org, as droid's yP checks a +// key before it is used ("invalid-api-key" when whoami refuses it). + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "regexp" + "time" +) + +// factoryKeyPrefix is droid's Uh: what a Factory API key starts with. +const factoryKeyPrefix = "fk-" + +var factoryKeyRe = regexp.MustCompile(`\bfk-[A-Za-z0-9_\-]{8,}`) + +// factoryKeysIn finds the API keys in pasted text: one a line, in a .env +// line (FACTORY_API_KEY=fk-…) or in JSON, each once. +func factoryKeysIn(texts []string) []string { + var out []string + seen := map[string]bool{} + for _, s := range texts { + for _, k := range factoryKeyRe.FindAllString(s, -1) { + if !seen[k] { + seen[k] = true + out = append(out, k) + } + } + } + return out +} + +// ImportFactoryKeys adds Factory accounts by their API keys, found in the +// pasted texts, each checked with whoami first, and says what became of +// each. +func ImportFactoryKeys(ctx context.Context, texts []string) ([]ImportedAccount, error) { + keys := factoryKeysIn(texts) + if len(keys) == 0 { + return nil, errors.New("no Factory API keys (fk-…) in it") + } + if len(keys) > maxGoogleImport { + return nil, fmt.Errorf("%d keys; at most %d at a time", len(keys), maxGoogleImport) + } + out := make([]ImportedAccount, len(keys)) + for i, k := range keys { + out[i] = factoryAddKey(ctx, k) + } + forgetAccountCaches() + return out, nil +} + +// factoryKeyName is a key as it is shown before whoami names its account. +func factoryKeyName(k string) string { + if len(k) <= 10 { + return k + } + return k[:7] + "…" + k[len(k)-4:] +} + +func factoryAddKey(ctx context.Context, key string) ImportedAccount { + wctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 20*time.Second) + defer cancel() + c := factoryCreds{Access: key, Key: true} + who, err := factoryWhoami(wctx, c) + if err != nil { + return ImportedAccount{User: factoryKeyName(key), Status: "failed", Error: "Factory didn't take this key: " + err.Error()} + } + c.Region, c.Prem, c.Email, c.UserID = who.Region, who.Prem, who.Email, who.UserID + user := firstNonEmpty(who.Email, who.UserID) + if user == "" { + return ImportedAccount{User: factoryKeyName(key), Status: "failed", Error: "Factory took the key but didn't say whose account it is"} + } + status := "added" + if l, ok := factoryLookup(user); ok { + status = "updated" + if was, ok := factorySaved(l); ok && was.Key && was.Access == key { + status = "exists" + } + } + auth, err := json.Marshal(c) + if err != nil { + return ImportedAccount{User: user, Status: "failed", Error: err.Error()} + } + if err := addSideLogin(savedLogin{Agent: "factory", User: user, Auth: auth}, "", func(savedLogin) {}); err != nil { + return ImportedAccount{User: user, Status: "failed", Error: err.Error()} + } + _ = editSideLogin("factory", user, func(ls []savedLogin, i int) ([]savedLogin, error) { + ls[i].Lapsed = "" + return ls, nil + }) + return ImportedAccount{User: user, Status: status} +} diff --git a/internal/provider/factory_key_test.go b/internal/provider/factory_key_test.go new file mode 100644 index 00000000..afc3ab98 --- /dev/null +++ b/internal/provider/factory_key_test.go @@ -0,0 +1,87 @@ +package provider + +import ( + "context" + "io" + "net/http" + "strings" + "sync" + "testing" +) + +// #506: a Factory account added by its API key, as droid takes +// FACTORY_API_KEY: whoami asked with the key says whose it is; the account's +// requests carry the key as the bearer with droid's headers and no active +// org (droid's comes from a sign-in), and nothing is renewed with WorkOS. A +// key whoami refuses isn't added; the same key again is "exists". +func TestFactoryAPIKey(t *testing.T) { + signIn(t) + const good, bad = "fk-good_0123456789abcdef", "fk-bad_0123456789abcdef" + var mu sync.Mutex + var sent []http.Header + factorySite(t, func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + switch { + case strings.HasPrefix(r.URL.Path, "/wos/"): + t.Errorf("WorkOS asked for a key account: %s", r.URL.Path) + w.WriteHeader(400) + case r.URL.Path == "/api/cli/whoami": + if auth != "Bearer "+good { + factoryJSON(w, 401, map[string]any{"detail": "Invalid API key"}) + return + } + factoryJSON(w, 200, map[string]any{"userId": "user_k", "orgId": "fac_K", "email": "key@example.com"}) + case r.URL.Path == "/api/llm/a/v1/messages": + mu.Lock() + sent = append(sent, r.Header.Clone()) + mu.Unlock() + // a key's org isn't one droid sends: the header refused, as + // Factory refuses an org the caller didn't pick + if auth != "Bearer "+good || r.Header.Get("X-Factory-Org-Id") != "" { + w.WriteHeader(403) + io.WriteString(w, `{"detail":"Forbidden"}`) + return + } + io.WriteString(w, `{"type":"message"}`) + default: + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + w.WriteHeader(404) + } + }) + + res, err := ImportFactoryKeys(context.Background(), []string{"FACTORY_API_KEY=" + good + "\n" + bad + "\n" + good}) + if err != nil { + t.Fatal(err) + } + if len(res) != 2 || res[0].User != "key@example.com" || res[0].Status != "added" || res[1].Status != "failed" || strings.Contains(res[1].User, "0123456789") { + t.Fatalf("imported %+v", res) + } + if c := factoryKept(t, "key@example.com"); !c.Key || c.Access != good || c.Refresh != "" || c.Active != "" { + t.Fatalf("kept %+v", c) + } + p, ok := find(Accounts(), "factory") + if !ok || p.Account.User != "key@example.com" { + t.Fatalf("account: %v %+v", ok, p.Account) + } + if code, b := factorySend(t, p); code != 200 { + t.Fatalf("a key account's request: %d %s", code, b) + } + mu.Lock() + h := sent[len(sent)-1] + mu.Unlock() + if h.Get("Authorization") != "Bearer "+good || h.Get("X-Factory-Client") != "cli" || h.Get("User-Agent") != "factory-cli/"+factoryVersion || h.Get("X-Factory-Org-Id") != "" { + t.Fatalf("sent %v", h) + } + // still no org after the request, and no whoami asked for one + if c := factoryKept(t, "key@example.com"); c.Active != "" { + t.Fatalf("a key account got an org: %q", c.Active) + } + + res, err = ImportFactoryKeys(context.Background(), []string{good}) + if err != nil || len(res) != 1 || res[0].Status != "exists" { + t.Fatalf("again: %+v %v", res, err) + } + if _, err := ImportFactoryKeys(context.Background(), []string{"sk-not-factory"}); err == nil { + t.Fatal("no key in it, and no error") + } +}