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:
1 parent
e0121f71b9
commit
bb5e8a2b61
12 files changed
+532
-17
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 经由此路由",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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, []);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user