factory: the Gateway page says when an older magpie serves the gateway, and a Factory account can be added by its API key (#506, 黄瑞 and aohun: Factory 403 "Factory refused this account the request; check that droid… sign in to it again" on v0.1.630; aohun: support FACTORY_API_KEY as droid2api does) — the 403's wording quoted in #506 was removed in 5523eb17 (first in v0.1.551; git grep finds it in v0.1.550, not in v0.1.630), so those requests were served by a magpie at v0.1.550 or older still holding :3425 (a magpie serve, a second copy, one that didn't quit): serveGateway leaves the port to any magpie answering GET / and the page said only "running · served by another magpie", so none of the later Factory fixes (droid's opening line on /api/llm/o in v0.1.630 among them) reached their requests. gateway.ServedBy reads the serving magpie's version from its GET / answer (Serving wraps it); the providers state carries it with older when it is below this one's, the Gateway card then says "running · served by magpie <v>, older than this one" with an amber dot and that agents' requests go through it without this version's fixes until it quits, when this one takes over within 15 s (zh too, wrapped, never cut off), and the log says so. A Factory API key (fk-…) is added from the import box (Factory's accounts list "Add an account by API key…", and "Use an API key instead…" on the warning before a sign-in): as droid's om() takes FACTORY_API_KEY, the key is the bearer with droid's headers, never renewed, and sends no X-Factory-Org-Id (droid's active org comes from a sign-in, st() is null for a key); whoami asked with the key says whose it is and its region/host, as droid's yP checks a key ("invalid-api-key" when refused), and a refused key isn't added. factoryFresh hands a key account on as it is (no WorkOS, no whoami for an org) and factoryMendOrg leaves it alone; when Factory has moved to its plugin the key isn't offered, the built-in being what keeps it. No request was made to Factory, no real key or account was tried, and whether Factory serves a key's requests from agents other than droid is not known. TestGatewayServedByAnOlderMagpie (fake magpie answering 0.1.550/0.1.630/0.1.640/no version), TestFactoryAPIKey (fake Factory: import, bearer and headers, no org, no WorkOS, refused key, "exists" again; fails with a 403 without the key handling in factoryFresh) and gateway-older.test.cjs, factory-key.test.cjs (Chromium and WebKit, en and zh) pass; login-import, factory-sub, plugin-moved-signin, gateway-fold pass in WebKit (Chromium's channel launch lacks ICU here). go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass.

This commit is contained in:
yetone committed 2026-10-02 14:58:49 +08:00
1 parent e0121f71b9
commit bb5e8a2b61
12 files changed
+532 -17

No files matched your search

+30 -3
View File
@@ -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.
+1
View File
@@ -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; }
+29 -9
View File
@@ -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");
+9
View File
@@ -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 经由此路由",
+8 -2
View File
@@ -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)
+55
View File
@@ -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)
}
}
}
+14 -1
View File
@@ -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 {
+111
View File
@@ -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, []);
});
}
}
+79
View File
@@ -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, []);
});
}
}
+9 -2
View File
@@ -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 {
+100
View File
@@ -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}
}
+87
View File
@@ -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")
}
}